The Tower is a raw disk image that boots into a configured, self-governing
Naive region. It is built once with Packer and is usable three ways from the
same artifact: bare metal, a local QEMU VM, and an AWS AMI.
Build — Packer + Ubuntu 24.04 autoinstall
image/tower.pkr.hcl drives a QEMU build of x86_64 Ubuntu 24.04
live-server via autoinstall (ds=nocloud-net, serving
image/http/{user-data,meta-data}). It boots UEFI/q35 with OVMF and emits a
raw disk. KVM makes it fast on Linux CI; macOS has no KVM, so a local build
runs under QEMU TCG (slow) — the authoritative build/boot gate is Linux CI.
The GPT layout
The autoinstall lays down a GPT disk (http/user-data):
Provisioning scripts
Packer runs the provisioners in order:
10-docker.sh — Docker CE + Compose v2 + gVisor runsc, registered
with Docker and with containerd’s SystemdCgroup=true (the well-known fix so
runsc containers start on a systemd-cgroup host). default-runtime stays
runc; runsc is the sandbox for the agent runtime.
20-tpm-luks.sh — installs the firstboot LUKS enroller (see below);
it does not seal at build time.
30-tailscale.sh — installs Tailscale but leaves it OFF (the human
admin plane only; the owner runs tailscale up post-claim if wanted).
40-region.sh — docker loads the preloaded image tarballs and installs
compose.tower.yaml + the systemd units.
50-firstboot.sh — installs the firstboot orchestrator and seals the
build (drops the Packer passwordless-sudo, cloud-init clean, truncates
machine-id so the next boot is a true firstboot).
Firstboot — sealing to the TPM
The seal must bind to the deployment’s TPM (swtpm in a local VM, NitroTPM on
AWS, a discrete/fTPM on metal), so it happens at first boot on the target, never
in the build.
naive-firstboot.service (ordered Before=naive-region.service, guarded by a
marker file) runs once:
/data → LUKS2 + TPM auto-unlock. A transient key creates the LUKS2
header, then systemd-cryptenroll --tpm2-device=auto --tpm2-pcrs=7 binds
unlock to PCR 7 (Secure Boot state). crypttab is written for
auto-unlock on every subsequent boot — no passphrase. PCR 11 (kernel/initrd)
is a beta hardening step.
- An owner recovery key is generated with
systemd-cryptenroll --recovery-key and printed to the console once. It is the break-glass
path if TPM state changes; the operator records it out-of-band.
- Vault root + config are written onto the now-encrypted
/data
(/data/vault/root.key, a per-Tower GOVERNOR_TOKEN in
/data/config/naive.env, an empty runtime-tokens.json).
Then naive-region.service runs docker compose -f /opt/naive/compose.tower.yaml up -d — the production topology: the preloaded
images, vaultd:tower (reveal compiled out), the real runsc
agent-runtime, state on the LUKS/TPM /data, and api/gateway published to guest
loopback only (no inbound WAN).
compose.tower.yaml is what boots on the image (preloaded tags, production
isolation). image/compose.yaml is the dev/CI-topology compose (build contexts
- test scaffolding) used by the topology test and the compose quickstart.
Run it locally — QEMU + swtpm
scripts/run-vm.sh boots the raw image under QEMU with a software TPM
(swtpm) and OVMF, using KVM when available or TCG otherwise, with the api /
gateway ports forwarded. Because swtpm is a real TPM 2.0 to the guest, the
cryptenroll PCR-7 seal and vault unlock run exactly as they will on hardware.
See the local VM tutorial.
Publish to AWS — NitroTPM
image/publish-ami.sh turns the same raw image into an AMI:
It uploads the raw to S3, runs ec2 import-snapshot (Format=RAW), waits
for completion, then:
--boot-mode uefi --tpm-support v2.0 is what wires NitroTPM to the instance,
so firstboot’s PCR-7 seal and vault unlock run unmodified on Nitro UEFI
instances (m5 / c5 / m6i). No inbound security-group rules are needed for the
region itself. See the AWS tutorial.
build-tarballs.sh (docker save region + vaultd:tower + Postgres) and
publish-ami.sh are ready. The end-to-end publish additionally requires the
account’s vmimport service role and a naive-tower.raw produced by the CI
Packer job (local macOS is TCG-only).