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 25.10 (Questing Quokka, kernel 6.17+) GCP image
produced by mero-tee/ubuntu.pkr.hcl + mero-tee/playbook.yml. The kernel is
pinned at ubuntu-2510-amd64 (from ubuntu-os-cloud) because RTMR3 sysfs
support needs kernel 6.17+. Component versions are pinned in
mero-tee/versions.json:
| Component | Version key | Value at time of writing |
|---|---|---|
| Node image | imageVersion |
2.3.51 |
| merod / meroctl / mero-auth (core tag) | merodVersion |
0.11.0-rc.17 |
| 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 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 | 2428 |
swarm-port |
merod libp2p swarm port | 2528 |
tee-release-version |
written as MERO_TEE_VERSION into /etc/calimero/merod.env (sanitized/escaped) |
unset → env file removed |
metrics-endpoint |
if set, configures and starts vmagent |
unset → vmagent stays off |
metrics-secret-name |
secret name for authenticated metrics push | none |
logs-endpoint |
if set, configures and starts vector |
unset → vector stays off |
logs-secret-name |
secret name for authenticated log push | none |
merod-version |
only used to download binaries at boot if they were not baked into the image | 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.
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. 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 it downloads them from thecorerelease matchingmerod-versionmetadata. -
Apply
tee-release-version. If set, it writesMERO_TEE_VERSION=<value>to/etc/calimero/merod.env, which the merod unit loads viaEnvironmentFile. -
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. 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.