Skip to content

Production Deployment

A production cluster is merod nodes, each in an Intel TDX VM booted from a signed mero-tee image: three or more for redundancy, or one that grows (below). Calimero runs shared clusters and dedicated ones (Calimero Cloud); the steps here are the same for either. Nodes run on Linux only. This page is what you decide and configure; the flags are in the merod reference and the file in Configuration.

Piece What it is
Nodes merod run in TDX mode (no --dev), one per TD. Each serves the node API on --listen and is reached at its --public-url.
mero-kms Releases each node’s storage key after verifying its quote against the signed release policy.
Package registry Where nodes fetch signed .mpk releases (--registry-url). Optional if every release is uploaded.
Backup storage A directory (--backup-dir) that receives sealed app snapshots.
TLS front Whatever terminates HTTPS for browsers in front of each node.
DNS A name per node, and a wildcard name for --apps-domain if you serve app frontends.

The image provides MERO_TEE_VERSION (the mero-tee release it is) and MERO_TEE_PROFILE (its profile). A TDX node refuses to start without them, because its attestation names them and it judges its peers by them.

Each node takes a storage key K_r = HKDF-SHA256(KMS key, salt calimero.cloud.storage.v1, info = --cluster), and seals everything on disk under the cluster key K_c, which it keeps wrapped under K_r (The cluster key). In TDX mode the KMS key comes from mero-kms only:

Terminal window
merod --node n1 init ... --kms-url https://kms.internal.example.com/ \
--kms-identity /data/disk-identity --cluster eu1
  • mero-kms is trusted only if its own quote matches the signed release the environment names (MERO_KMS_RELEASE_TAG, else MERO_KMS_VERSION, else MERO_TEE_VERSION); the key is sealed to this TD.
  • The KMS derives one key per identity. All nodes of a cluster must present the same disk identity file and the same --cluster, so they derive the same K_r. The founding node takes its K_r as K_c; the others, and later every node of a new release (whose mero-kms gives another K_r), take K_c from a peer. merod creates the identity file if it is missing; create it once and give every node a copy.
  • --storage-key and --storage-key-file are refused without --dev: a key the host could hand the node would let whoever runs the host read the disks.

merod kms disk-key is the same client as a command, for a boot script that needs a disk-unlock key before merod starts; it writes the key to tmpfs or ramfs only.

  1. Give every founding node the same cluster settings: the same --cluster, KMS identity, --peers list (every founding node, as id=url), --rp-id, --origins and governance flags, and a unique --node-id and --public-url.

    Terminal window
    merod --node n1 init --node-id 1 --public-url https://n1.eu1.example.com/ \
    --cluster eu1 \
    --peers 1=https://n1.eu1.example.com/,2=https://n2.eu1.example.com/,3=https://n3.eu1.example.com/ \
    --kms-url https://kms.internal.example.com/ --kms-identity /data/disk-identity \
    --rp-id example.com --origins https://app.example.com \
    --ops-token-sha256 "$OPS_TOKEN_SHA256" --backup-dir /backups \
    --registry-url https://registry.example.com/
  2. Bootstrap on one node. Add --bootstrap to exactly one founding node’s first start. It forms the system group with --peers as voters; the others wait to be contacted. A restart with --bootstrap still set does nothing.

  3. Start them all with merod --node nX run. The first leader writes the genesis (the governance below), registers the --peers in the fleet directory and adds the first session-signing key. Nobody can sign up before genesis.

  4. Check it formed:

    Terminal window
    meroctl --api https://n1.eu1.example.com/ --tee-profiles "$PROFILE" network status
    meroctl --api https://n1.eu1.example.com/ --tee-profiles "$PROFILE" \
    --ops-token "$OPS_TOKEN" ops status

    ops status shows the system group’s voters and leader and the fleet.

Nodes added later are not in --peers: see Add a node.

1 · one noden1system voterapp Aapp Bapp C--bootstrap withone peer; apps getmin(--replicas, nodes)replicas: one eachno redundancy2 · add nodesn1voterABCn2learner→ votern3learner→ voterPOST /ops/nodes {id, addr, zone}→ the system leader adds eachas a learner; once two havecaught up, both become voterssystem voters 1 → 3 (never 2)3 · rebalancen1voterABCn2voterABCn3voterABCmdma's rebalancer readsGET /ops/placement, then onePOST /ops/apps/{app}/replicasat a time (bundle + blobs first)apps survive a node loss4 · drainnX drainingreplicas moved offfirst, thenDELETE /ops/nodes/{id}(system leader)refused while an appkeeps a replica there;a removed voter isreplaced by a caught-uplearnermerod never moves an app by itself: placement picks a new app's replicas, moves come through the Ops API.The system group grows to 3 voters by itself (zones no voter is in first) and no further; later nodes stay learners.Learners still serve: every node answers clients and forwards writes to the leaders.

A cluster may be bootstrapped on one node (--peers naming only itself) and grown. While it is one node, every app gets one replica and that node is a single point of failure. Each node registered later (POST /ops/nodes, with its zone) joins the system group as a learner; once two learners have caught up, the system leader makes them voters in one change, so the system group goes from one voter to three, never two, and never past three by itself. Apps placed while the cluster was small are spread by replica moves: mdma’s rebalancer reads GET /ops/placement and sends one POST /ops/apps/{app}/replicas at a time, and the app’s leader hands the new replicas the app’s bundle and blobs before they join. To retire a node, move its replicas away first; DELETE /ops/nodes/{id} is refused while an app keeps a replica there. The steps are in the runbooks: Grow from one node, Remove a node.

Three decisions are each given, when the cluster is formed, to either each app’s owner (the default; an organization’s owner for its apps, approving with a passkey) or the operator (for every app, with the ops token):

Flag The decision
--release-approvals Which mero-tee releases may hold apps’ data (cluster upgrades).
--app-upgrades Which bundle an app runs.
--outbound-approvals Which URL prefixes apps may call out to.

They are fixed at genesis, attested with every node’s receipt key, and reported to users (meroctl attest --context), so a user can check them before trusting the cluster. Give the operator a decision only where users trust it with code, or destinations, that can read their data. See Security.

The repository’s Dockerfile builds an image whose entrypoint is merod, with CALIMERO_HOME=/data and /data a volume. Every key can be an environment variable, so a container needs no init:

Terminal window
docker run -d --name n1 -p 2528:2528 -v n1-data:/data \
-e MEROD_NODE_ID=1 -e MEROD_PUBLIC_URL=https://n1.eu1.example.com/ \
-e MEROD_DATA_DIR=/data/n1 -e MEROD_CLUSTER=eu1 \
-e MEROD_PEERS=1=https://n1.eu1.example.com/,2=https://n2.eu1.example.com/,3=https://n3.eu1.example.com/ \
-e MEROD_KMS_URL=https://kms.internal.example.com/ -e MEROD_KMS_IDENTITY=/data/disk-identity \
-e MEROD_OPS_TOKEN_SHA256="$OPS_TOKEN_SHA256" \
-e MERO_TEE_VERSION="$RELEASE" -e MERO_TEE_PROFILE="$PROFILE" \
merod run

merod here is the image tag (docker build -t merod .); the arguments after it go to the merod binary.

The image EXPOSEs 2428 and 2528; only 2528 (the default --listen port) is used.

merod serves plain HTTP. What it serves is already sealed end to end to the attested node, so a TLS terminator in front of it sees only sealed bytes, plus /health and /attestation, which are public. You still need HTTPS in front:

  • Passkeys work only in a secure context, and a page on https:// cannot call an http:// API. Serve each node’s --public-url over HTTPS.
  • --public-url must be the URL clients and peers actually use, and it must reach that node, not any node: peers send Raft traffic to it, and the system directory records it.
  • One name may balance across nodes: a passkey ceremony or a confirmation begun on one node can finish on another, which takes the challenge from the node that issued it (internal/challenge/take).
  • /apps/{app}/events and /apps/{app}/presence/stream are server-sent event streams; turn off response buffering for them. Each node serves at most 4096 open streams.
  • Requests up to 64 MiB must pass (bundle and package uploads).

With --apps-domain apps.example.com and a wildcard DNS name *.apps.example.com pointing at the cluster, each installed package’s frontend is served from its signed release at {label}.apps.example.com and at this cluster’s {cluster_label}.apps.example.com, and each deployment’s at its own {dlabel}.apps.example.com. TLS for the wildcard name is the front’s job; a front shared by several clusters routes each cluster label to its cluster. Keep --cluster stable: the cluster labels derive from it. A custom domain a package’s manager registered as a served origin also needs its own DNS record to the front and a certificate for its name; GET /ops/served-origins lists them. See Networking.

  • No node runs with --dev, --storage-key, --storage-key-file or --allow-unsigned-bundles.
  • MERO_TEE_VERSION and MERO_TEE_PROFILE come from the image; --peer-profiles lists only the profiles you run.
  • Every node has the same --cluster, disk identity, --rp-id, --origins and governance flags.
  • Exactly one founding node had --bootstrap on its first start.
  • The ops token is configured as --ops-token-sha256, and the token itself is kept outside the machines.
  • --backup-dir is set and its contents leave the machine.
  • --rp-id is the domain users sign in on; --origins lists the HTTPS origins of your frontends.
  • Quotas are set for your cluster (meroctl ops set-quotas).
  • Something collects /ops/metrics from every node through the sealed channel; see Observability.