Deployment, Migration, and Docker Compose

v1.0.0 — archived Earlier v0.x (Zig) docs →

Archived: Do not adopt Amulet for new production deployments. This page remains for recovery, migration, and existing operators. Start with the migration guide.

Boundary for existing deployments

Amulet does not solve secret zero. It cannot protect a decrypted secret if an attacker controls the same host or user, root, the Docker socket, the unlock credential, or a CI job that receives that credential. Encryption at rest and Locked mode can still reduce damage when the vault file alone is exposed.

Locked vs Portable: behavior in existing deployments

Environment Historical mode Operational notes
Physical machine / fixed VM Locked Threat model: prevents decryption if only the vault file is exfiltrated to a different host. Does not protect against an attacker who already has a shell on the same machine.
VM clone / template Locked Uniqueness required: regenerate machine-id on each instance after cloning (e.g. systemd-machine-id-setup). Duplicate IDs mean vaults sealed on one instance can be decrypted on any clone with the same ID — intended isolation does not hold.
Windows (Sysprep) Locked MachineGuid changes on re-generalization. Seal per node after deployment; do not bake a sealed vault into the golden image. If MachineGuid changes after sealing, the vault becomes unrecoverable — follow the migration steps below.
Developer laptop Locked (per person) Each developer seals on their own machine.
CI (GitHub Actions, etc.) Portable Runner instances change each run. The passphrase is the primary cryptographic boundary. A passphrase or identity key injected into the same job cannot protect against compromise of that job.
Containers / Kubernetes Portable Pod machine_id is often unstable or shared. Passphrase and injection-path security are primary controls; Docker-socket, node, and workload compromise are out of scope.
Migration / recovery Portable Cross-machine decryption is intentional.

OS reinstall / machine identity change: Locked vaults become unrecoverable if machine_id changes (e.g. Linux: OS reinstall; macOS: logic board swap; Windows: clean OS install or image restore). Include a recovery procedure in your runbook (see below).

For existing installations:

  • Treat the host, CI runner, and unlock-credential path as part of the trusted computing base.
  • Do not treat Locked mode as a substitute for platform access control or a maintained secret manager.
  • Do not share a Locked vault across machines.

Operational deep-dives

Locked threat model

Locked mixes the OS-reported machine identifier into the Argon2id password input (/etc/machine-id on Linux, IOPlatformUUID on macOS, MachineGuid in the registry on Windows). The machine ID is an identifier, not a secret. Locked mode mainly adds a boundary when only the vault file reaches a host with a different machine ID. Same-host, same-user, root, duplicated-ID, process-memory, and unlock-credential access remain out of scope.

VM clones and machine-id uniqueness

Amulet considers any two hosts with the same machine_id to be the "same machine". On Linux, cloning a VM image without reinitializing the ID is a common deployment mistake. The practical consequence:

  • Duplicate IDs: vault sealed on instance A can be decrypted on instance B if both share the same machine_id. Environment isolation (e.g. dev vault readable in prod) silently breaks.
  • machine-id changes after sealing: if the host's machine-id changes after a vault was sealed there (e.g. systemd-machine-id-setup runs, or the OS is reinstalled), that vault can no longer be decrypted on that host — same failure mode as an OS reinstall.

Recommended practice: for template-based Linux deployments, blank the machine-id in the golden image (> /etc/machine-id) so that systemd-machine-id-setup runs automatically on first boot, giving each instance a unique ID before any sealing happens.

CI/CD with Portable mode

In ephemeral environments, existing workflows used Portable mode because machine_id changes with every runner. The passphrase is the primary cryptographic boundary, but a CI secret store does not protect it after injection. A compromised job that receives the passphrase or identity key can unseal the same secrets. Prefer migration to the platform's maintained secret-management mechanism.


Migration and disaster recovery

Vault file copy ≠ recoverable backup for Locked vaults

Backup type Contents Recoverable on a host with a different machine_id?
Vault file copy Encrypted binary ❌ Locked: requires matching machine_id
Plaintext unsealed on old machine Raw secret value ✅ Re-seal on new machine
Portable vault copy Encrypted binary ✅ Passphrase alone is sufficient

Note: VM clones sharing the same machine_id can decrypt each other's Locked vaults. See the VM clones note in docs/security.md for details.

Planned machine migration

While the old machine is still running:

# 1. Extract on the old machine
printf "mypassphrase\n" | amulet unseal SECRET_KEY --file secrets.vault

# 2. Re-seal on the new machine (Locked binds to the new machine_id)
echo -n "<extracted value>" | amulet seal SECRET_KEY --file secrets.vault

Sudden machine failure

If the old machine is unbootable, a Locked vault cannot be recovered. Prepare in advance:

  • Keep secrets in a separate secure location (password manager, etc.)
  • Or maintain a Portable vault as an offline backup

Multi-device development

The same Locked vault cannot be shared across devices. Choose one of:

  • Separate vault per device — each device seals its own (independent Locked vaults)
  • Shared Portable vault — share the passphrase securely, use the same vault everywhere
  • Portable for development, Locked for production — mix modes per environment

Docker Compose / Podman Compose

The most reliable approach is to write the secret to a short-lived temp file and pass it with --env-file.

Step-by-step

1. Create a temp file and register cleanup:

TMP_ENV=$(mktemp)
chmod 0600 "$TMP_ENV"
trap "rm -f '$TMP_ENV'" EXIT

Optional — reduce disk exposure (Linux): On Linux, mktemp -p /dev/shm is a good option when /dev/shm exists (tmpfs-backed on most distros). In an interactive desktop session where $XDG_RUNTIME_DIR is set, mktemp -p "$XDG_RUNTIME_DIR" is another common pattern — omit the fallback to /tmp, as /tmp is not always tmpfs and would defeat the purpose. Either way this is best-effort: swap or storage configuration can affect whether plaintext truly stays off disk. On macOS /dev/shm is not available; the default mktemp is fine there.

2. Write one KEY=value line. Use two commands — some zsh versions do not merge stdout from subshell redirections reliably:

printf 'OPENAI_API_KEY=' > "$TMP_ENV"
printf "mypassphrase\n" | amulet unseal OPENAI_API_KEY --file secrets.vault >> "$TMP_ENV"

On bash, a subshell one-liner also works:

( printf 'OPENAI_API_KEY='; printf "mypassphrase\n" | amulet unseal OPENAI_API_KEY --file secrets.vault ) > "$TMP_ENV"

If wc -c "$TMP_ENV" equals only the OPENAI_API_KEY= prefix, unseal did not append — check passphrase, key name, --file, or Locked-mode machine mismatch.

3. Run Compose:

docker compose --env-file "$TMP_ENV" config   # dry-run
docker compose --env-file "$TMP_ENV" up

# Podman
podman compose --env-file "$TMP_ENV" up

4. Teardown:

docker compose down
rm -f "$TMP_ENV"    # or just exit the shell (trap handles it)

If you run compose down without --env-file, Compose may warn that OPENAI_API_KEY is unset — harmless for removal.

Podman on macOS

If podman compose cannot connect, start the VM: podman machine start (run podman machine init once first).

$ escaping in Compose YAML

Compose interpolates $VAR / ${VAR} in YAML strings. In command: blocks, use $$ so the container shell receives a literal $ (e.g. $$OPENAI_API_KEY). Avoid bash-only expansions like ${#VAR} — Compose treats them as invalid interpolation.

Note: The temporary file briefly holds plaintext on disk. Always use trap to ensure deletion. This bridge is retained for existing-user recovery and migration, not recommended as a new production design.