Deploy the node image
How to build a GCP TDX merod node image with this repository and boot it as an
Intel TDX Confidential VM. This is the layer underneath the
runbooks: the runbooks point an already-running merod at
the KMS; this page gets a node image built, launched, and healthy in the first
place. Everything below is grounded in the Packer/Ansible sources under
mero-tee/ and the two GCP workflows — confirm exact flags against
--help and your checked-out version before running against production.
What gets baked into an image
Section titled “What gets baked into an image”A node image is an Ubuntu 26.04 LTS (Resolute Raccoon) GCP image produced by
mero-tee/ubuntu.pkr.hcl + mero-tee/playbook.yml. The base is pinned at
ubuntu-2604-lts-amd64 (from ubuntu-os-cloud): RTMR3 sysfs support needs
kernel 6.17+, and an LTS is used because Canonical delists an EOL release from
that project, which fails the build — the previous pin on the interim 25.10 did
exactly that when it reached EOL. Component versions are pinned in
mero-tee/versions.json:
| Component | Version key | Value at time of writing |
|---|---|---|
| Node image | imageVersion |
2.3.102 |
| merod / meroctl / mero-auth (core tag) | merodVersion |
0.11.0-rc.67 |
| Traefik | traefikVersion |
3.5.0 |
| node-exporter | nodeExporterVersion |
1.9.1 |
| vmagent | vmagentVersion |
1.132.0 |
| Vector | vectorVersion |
0.50.0 |
The playbook.yml applies these roles in order: mero-traefik,
calimero-core (bakes merod/meroctl/mero-auth at the core tag so
calimero-init skips the runtime download), merotee (the init script,
systemd units, and — for read-only profiles — the fleet sidecar),
node-exporter, vmagent, vector, cleanup, and — only for
locked-read-only — merod-lockdown followed by merotee-conformance.
Profiles
Section titled “Profiles”The lockdown_profile Packer variable selects one of three profiles
(mero-tee/ubuntu.pkr.hcl validates against exactly this set):
| Profile | Intended use | merod mode | Lockdown role |
|---|---|---|---|
debug |
Local / dev | full |
not applied |
debug-read-only |
Integration / pre-production | read-only |
not applied |
locked-read-only |
Production | read-only |
applied + conformance |
merod_mode is derived in ansible/roles/merotee/defaults/main.yml: it is
read-only for debug-read-only and locked-read-only, and full for
debug. In read-only mode the boot script initializes merod with
--mode read-only and disables the client-facing servers (see
the boot sequence below).
The merod-lockdown role (ansible/roles/merod-lockdown/tasks/main.yml)
hardens the production image so its measurements differ from the debug
profiles. It:
- R1 removes
openssh-server/openssh-clientand deletes/etc/ssh; - R2 masks all
getty@/serial-getty@ttyS0/console-getty/getty.targetunits and stripsconsole=from the kernel cmdline; - R3 masks
emergency/rescue/debug-shell(and overrides emergency/rescue toreboot -f); - R4 locks the root account, empties
/etc/securetty, and removes theubuntuuser (done last, since Packer SSHes in asubuntu); - R5 masks
google-startup-scriptsand thecloud-init*units so no GCP metadata script is ever executed — metadata is only read as data by the init script’s whitelist.
Prerequisites
Section titled “Prerequisites”- Packer and Ansible — the build driver and provisioner.
- GCP credentials with rights to create an image in the target project.
- The Packer plugins pinned in
ubuntu.pkr.hcl:googlecompute 1.2.1andansible 1.1.3(installed bypacker init).
Build an image
Section titled “Build an image”-
Install the pinned Packer plugins:
Terminal window cd mero-tee./init.sh # runs: packer init ubuntu.pkr.hcl -
Build. For a single profile with all versions read from
versions.json, use the wrapper — it resolves every pinned version and passes the Packer vars for you:Terminal window ./build-and-release.sh locked-read-onlyWith no profile argument it builds all three profiles (
locked-read-only,debug-read-only,debug) in turn. To drive Packer directly instead:Terminal window packer build -var-file=ubuntu-x86.pkrvars.hcl ubuntu.pkr.hcl
The build runs on a widely-available n2-standard-2 host
(instance_type in ubuntu-x86.pkrvars.hcl) — no TDX is needed to build the
image, only to run it. The build host defaults are project_id
calimero-p2p-development, region europe-west4, zone europe-west4-a,
overridable via PACKER_GCP_PROJECT_ID / PACKER_GCP_REGION /
PACKER_GCP_ZONE / PACKER_GCP_SUBNETWORK. The base source image is hardcoded
for release reproducibility and cannot be overridden.
Where images are published
Section titled “Where images are published”Packer publishes each image into the GCP project with a deterministic name and
family (from ubuntu.pkr.hcl):
- Image name:
merotee-ubuntu-questing-25-10-<profile>-<version-with-dashes>(e.g.merotee-ubuntu-questing-25-10-locked-read-only-2-3-51). - Image family:
merotee-ubuntu-questing-<profile>. - Disk: 20 GB
pd-ssd.
The questing-25-10 in those names is a frozen identifier, not the base
release — the base is 26.04 LTS. mdma’s dispatcher matches on these prefixes, so
they do not move when the base does.
The release-node-image-gcp.yaml workflow builds the images in CI and records
the published project / family in its release provenance. Its VM-facing
defaults (used for the post-build attestation probe) match Calimero Cloud MDMA:
project cloud-486420, zone europe-west4-a, machine type c3-standard-4.
Launch a TDX Confidential VM
Section titled “Launch a TDX Confidential VM”Boot the image as an Intel TDX Confidential VM. The flags below are exactly
those the release-node-image-gcp.yaml and node-image-gcp-staging-probe.yaml
workflows use to create their ephemeral VMs:
gcloud compute instances create <instance-name> \ --project <vm-project> \ --zone europe-west4-a \ --machine-type c3-standard-4 \ --confidential-compute-type TDX \ --maintenance-policy TERMINATE \ --min-cpu-platform "Intel Sapphire Rapids" \ --no-service-account \ --no-scopes \ --image <image-name> \ --image-project <image-project> \ --subnet <subnetwork> \ --metadata merod-version=<merod-version> \ --tags meroteeGCP metadata the boot script reads
Section titled “GCP metadata the boot script reads”calimero-init reads a fixed whitelist of instance metadata attributes
(ansible/roles/merotee/templates/calimero-init.sh.j2) — never arbitrary
scripts. All are optional except where noted:
| Metadata key | Effect | Default |
|---|---|---|
server-port |
merod admin/server port (1–65535; anything else falls back to the default) | 2428 |
swarm-port |
merod libp2p swarm port (1–65535; anything else falls back to the default) | 2528 |
tee-release-version |
written as MERO_TEE_VERSION into /etc/calimero/merod.env (sanitized/escaped), with MERO_TEE_MIN_VERSION (the image’s own version) and MERO_TEE_PROFILE (the image’s profile), so merod verifies the KMS against that profile’s signed policy. Refused if older than the image (downgrade protection) |
unset → env file removed |
kms-url |
the VPC-internal URL of the release’s KMS cluster, which the data-disk key and the store key come from; verified against tee-release-version’s signed policy |
unset → locked-read-only refuses to create a node; debug profiles create an unencrypted one |
metrics-endpoint |
if set, configures and starts vmagent and node_exporter |
unset → vmagent stays off |
logs-endpoint |
if set, configures and starts vector |
unset → vector stays off |
observability-token |
the bearer token both shippers present; written to /etc/vector/provided_token and used via the provided secret provider |
unset → the node falls back to the credential MDMA delivers on a should-join poll |
merod-version |
only used to download binaries at boot if they were not baked into the image — debug profiles only; locked-read-only refuses to boot rather than run an unmeasured binary |
required only when unbaked |
Observability is off by default (R7): vmagent and vector are stopped and
disabled at boot and started only when the matching *-endpoint metadata is
present. node_exporter is started alongside vmagent, because it is the only
thing the default scrape config targets — its unit file is installed by the
image but is not enabled, so without that it would be a dead scrape target.
The calimero-init boot sequence
Section titled “The calimero-init boot sequence”calimero-init.service is a oneshot unit that runs
/usr/local/lib/calimero/init.sh after network-online.target; merod,
mero-auth, traefik, and (in read-only profiles) fleet-sidecar all order
themselves After=calimero-init.service, and merod, mero-auth and
fleet-sidecar also Require it, so none of them writes to /mnt/data if the
data disk could not be unlocked. Its log is /run/calimero/calimero-init.log
(RAM) and the journal; the journal is volatile, so nothing it logs persists on
the boot disk. The script
(ansible/roles/merotee/templates/calimero-init.sh.j2) runs, in order:
-
Extend RTMR3 for attestation. It reads
calimero.root_hashfrom/proc/cmdline, computes an SHA-384 digest ofcalimero-rtmr3-v2:<role>:<profile>:<root_hash>, and writes the 48-byte digest into the TDX guest sysfs (/sys/class/misc/tdx_guest/measurements/rtmr3:sha384, falling back to.../mr/rtmr3:sha384). This makes each image’s runtime measurement unique and unforgeable. It is best-effort: if the sysfs node is unavailable the node still boots, logging a warning. -
Disable observability by default (stops/disables
vmagentandvector), then reads the metadata whitelist above. -
Resolve binaries. If
merod/meroctl/mero-authare baked in/usr/local/bin(they are, for images built here), it uses them; otherwise, on the debug profiles only, it downloads them from thecorerelease matchingmerod-versionmetadata.locked-read-onlystops instead: a downloaded binary is in no measurement. -
Apply
tee-release-version. If set, it refuses a release older than the image’s own version (/etc/calimero/min-tee-release-version, baked at build), then writesMERO_TEE_VERSION,MERO_TEE_MIN_VERSIONandMERO_TEE_PROFILE(the image’s profile, which selects the release’s per-profile KMS policy) to/etc/calimero/merod.env, which the merod unit loads viaEnvironmentFile, and exports them for the steps below. -
Unlock the data disk. Swap must be off (it is refused on
locked-read-only). Then, for/dev/disk/by-id/google-data, the link GCE’s disk-naming udev rule makes for the disk attached asdata(the image ships the rule and itsgoogle_nvme_idhelper, vendored from GoogleCloudPlatform/guest-configs; if the link is still missing after 10 s, the one whole disk with nothing on it is used, and the journal says so):- blank (
blkid -pfinds no signature): fetch a key withmerod kms disk-key --create-identityinto a dedicated tmpfs,cryptsetup luksFormatLUKS2 (aes-xts-plain64,--integrity hmac-sha256), store the disk-unlock identity in acalimero-kms-identityLUKS2 token, open it as/dev/mapper/calimero-data, create ext4 on the mapper and mount it at/mnt/data. The integrity wipe covers the whole disk, so the first boot is slow (about 17 minutes for 200 GBpd-balanced); the journal logsIntegrity wipe: N% (… of … GB, …m…s left)every 30 s while it runs, and mdma’sMDMA_TEE_POST_BOOT_VERIFY_TIMEOUT_SECONDSmust outlast it. No fstab entry is written. - LUKS: read the identity from the token, fetch its key, open, mount.
- plain ext4 (a node from before disk encryption, or a debug node with no KMS): mounted as before, with a loud warning. Never reformatted.
- anything else, a failed probe, a LUKS header with no token, or an opened volume without ext4: the boot stops. Nothing is ever formatted.
The key is shredded as soon as the disk is open.
locked-read-onlyrefuses a blank disk withoutkms-url+tee-release-version, or on a merod withoutkms disk-key. Withephemeral-store=true, a tmpfs is mounted over/mnt/datainstead and the disk is untouched. - blank (
-
Initialize the node at
/mnt/data/calimero(nodedefault) if not already initialized, with--advertise-addressand the resolved ports. Inread-onlymode it passes--mode read-only. -
Apply read-only config. In
read-onlymode it runsmerod configto setserver.jsonrpc.enabled=false,server.sse.enabled=false, andserver.websocket.enabled=false— the client-facing write/subscribe surfaces are turned off, leaving only the read/admin path. A relay (a read-only profile built withfleet_delegated_access, the default for both read-only profiles) keepsserver.sse.enabled=true: delegated clients subscribe there, behindauth-node, scoped to their own contexts. Infull(debug) mode these remain enabled. -
Start services. It
daemon-reloads, enables and non-blocking-startsmerod,mero-auth, andtraefik; in read-only mode it also startsfleet-sidecarwhen that unit exists.
Verify the booted node
Section titled “Verify the booted node”-
Confirm it reached RUNNING and answers. After the VM is
RUNNING, the admin API is reachable on its external IP at theadmin_api_port(80in the CI probes). Thenode-image-gcp-staging-probe.yamlworkflow automates exactly this: it boots a TDX VM from a named image, waits for readiness, collects a TDX quote, and verifies it against Intel Trust Authority. Run it via Actions → Node image GCP staging probe withprofile,image_name, andimage_project(see the probe entry in the runbooks). -
Attest the measurements. Confirm the node’s MRTD and RTMR values (the RTMR3 extend from step 1 of the boot sequence, plus
calimero.role=node/calimero.profilemeasured into RTMR[2]) match the published, signed set. See Verification for the operator verify procedure and how to verify a release for the release-asset side.
Next steps
Section titled “Next steps”- Runbooks — point the booted node at the KMS and roll versions.
- Configuration reference — every runtime knob.
- Verification — MRTD/RTMR, root hash, operator verify.
- Verify a release — trust the release assets before you deploy them.