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 mkosi build and Ansible roles under
mero-tee/ and the 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) image built by mkosi
(mero-tee/mkosi-image) from mero-tee/playbook.yml’s roles. Its packages come
from a pinned snapshot of the Ubuntu archive (Snapshot= in mkosi.conf), so
two builds of one commit give the same image. RTMR3 sysfs support needs kernel
6.17+. The image boots as a UKI with an EROFS root behind dm-verity. Component
versions are pinned in mero-tee/versions.json:
| Component | Version key | Value at time of writing |
|---|---|---|
| Node image | imageVersion |
2.3.123 |
| merod / meroctl / mero-auth (core tag) | merodVersion |
0.11.0-rc.90 |
| 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 PROFILE the build is given selects one of three profiles
(mero-tee/mkosi-image/build.sh accepts 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; the image’s kernel cmdline carries noconsole=(the build refuses one); - R3 masks
emergency/rescue/debug-shell(and overrides emergency/rescue toreboot -f); - R4 locks the root account and empties
/etc/securetty; - 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”- mkosi (v27, as CI pins it) and Python with Jinja2 and PyYAML. CI installs
the rest with
scripts/release/mkosi/install-tools.sh. - Root: build as root, as CI does; unprivileged, mkosi’s user namespace gives every file root as its owner.
- GCP credentials only to upload and boot the image.
Build an image
Section titled “Build an image”-
Build one role and profile, with every version read from
versions.json:Terminal window sudo ROLE=node PROFILE=locked-read-only mero-tee/mkosi-image/build.sh OUTIt writes
OUT/merotee-node.raw(the disk),merotee-node.efi(the UKI) and mkosi’s package manifest.ROLEisnode,kmsoragent. -
Compute the measurements and upload, as the releases do:
scripts/release/mkosi/build-image.shbuilds, checks the command line, computes RTMR1–3 and packages the disk, andscripts/release/mkosi/create-gce-image.shcreates the GCE image.
To build, boot and probe an image on GCP TDX from a branch, run
node-image-mkosi.yaml (see
Build the images with mkosi).
Where images are published
Section titled “Where images are published”Each release image is published into the GCP project with a deterministic name
and family (scripts/release/mkosi/release-image-name.sh):
- 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: the disk mkosi writes, about 2 GB.
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. Must be https:// except on the debug and debug-read-only profiles |
unset → vmagent stays off |
logs-endpoint |
if set, configures and starts vector. Must be https:// except on the debug and debug-read-only profiles |
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 the dm-verity root hash (
roothash=) from/proc/cmdline, computes an SHA-384 digest ofcalimero-rtmr3-v3:<role>:<profile>:<roothash>, 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). Every block of the root filesystem is checked against that hash, so RTMR3 names the image’s whole root, its role and its profile. Onlocked-read-onlya missingroothash=or RTMR3 interface stops the boot; the debug profiles log a warning and go on. (Releases up to 2.3.118 extendedcalimero-rtmr3-v2:...withcalimero.root_hash, a build-time hash of a few directories.) -
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.