Deployment, Migration, and Docker Compose
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-setupruns, 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.vaultSudden 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'" EXITOptional — reduce disk exposure (Linux): On Linux,
mktemp -p /dev/shmis a good option when/dev/shmexists (tmpfs-backed on most distros). In an interactive desktop session where$XDG_RUNTIME_DIRis set,mktemp -p "$XDG_RUNTIME_DIR"is another common pattern — omit the fallback to/tmp, as/tmpis 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/shmis not available; the defaultmktempis 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" up4. 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
trapto ensure deletion. This bridge is retained for existing-user recovery and migration, not recommended as a new production design.