Ubuntu Production Deployment (systemd)
This guide covers deploying Amulet on Ubuntu 24.04 LTS (systemd 255) using
LoadCredential to inject the vault passphrase without exposing it in the
environment or on the command line.
Target: Ubuntu 22.04+ (systemd 247+). Ubuntu 20.04 (systemd 245) does not support
LoadCredential— see the Ubuntu 20.04 fallback.
Why LoadCredential
LoadCredential mounts the passphrase as a tmpfs file inside
$CREDENTIALS_DIRECTORY for the duration of the service process. The
credential is never passed as a command-line argument or stored in the process
environment, and it is cleaned up automatically when the service stops.
The unsealed secret is exported as an environment variable for the application
process. On Linux, root can read process environment variables via
/proc/<pid>/environ. This is expected server-level behaviour; host access
control (non-root app user, PermitRootLogin no) remains necessary.
1. Harden SSH
Before placing any secrets on the server, confirm these settings in
/etc/ssh/sshd_config:
PermitRootLogin no
PasswordAuthentication noWarning: Open a second SSH session as your sudo user before reloading, to avoid locking yourself out.
sudo systemctl reload ssh2. Place the vault file
Copy your secrets.vault to the server and set ownership so only the app
user can read it:
sudo mkdir -p /etc/amulet
sudo cp secrets.vault /etc/amulet/secrets.vault
sudo chown root:myapp /etc/amulet/secrets.vault
sudo chmod 640 /etc/amulet/secrets.vaultReplace myapp with the system user that runs your application.
3. Store the passphrase
Write the passphrase to a root-only file. Avoid shell history exposure by
reading from stdin. printf "%s" writes no trailing newline, which avoids
a passphrase mismatch when amulet unseal reads the credential file:
sudo mkdir -p /etc/amulet
sudo bash -c 'read -rs PASS && printf "%s" "$PASS" > /etc/amulet/passphrase'
sudo chmod 600 /etc/amulet/passphrase
sudo chown root:root /etc/amulet/passphrase4. Create a startup wrapper
This script unseals the secrets and launches the application with them
available as environment variables. Use the full path to amulet so the
script is not sensitive to systemd's PATH:
# /usr/local/bin/myapp-start.sh
#!/bin/sh
export API_KEY=$(cat "$CREDENTIALS_DIRECTORY/amulet-pass" \
| /usr/local/bin/amulet unseal API_KEY --file /etc/amulet/secrets.vault)
exec /opt/myapp/bin/myappSet ownership so myapp can execute it via the group bit:
sudo chown root:myapp /usr/local/bin/myapp-start.sh
sudo chmod 750 /usr/local/bin/myapp-start.shAdd one export line per secret your application needs.
5. Create the systemd service
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
After=network.target
[Service]
User=myapp
LoadCredential=amulet-pass:/etc/amulet/passphrase
ExecStart=/usr/local/bin/myapp-start.sh
Restart=on-failure
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myappSecurity summary
| Control | Effect |
|---|---|
chmod 600 on passphrase file |
Only root can read it |
LoadCredential |
Passphrase lands in tmpfs; cleaned up on service stop |
chown root:myapp 750 on wrapper |
Only root and myapp group can execute it |
User=myapp |
Application runs as a non-root user |
| Locked vault (default) | Vault is bound to this machine's machine_id; unreadable on another host |
PermitRootLogin no |
Root cannot log in directly over SSH |
PasswordAuthentication no |
SSH key required; brute-force password attacks are blocked |
Updating secrets after deployment
Normal update (one key)
echo -n "new_secret_value" | \
sudo amulet seal SECRET_KEY --file /etc/amulet/secrets.vault
sudo systemctl restart myappWhen SSH may disconnect (bulk update via temp file)
Interactive seal prompts can be interrupted if the SSH session drops
mid-input. For bulk updates on a VPS, write the new values to a temp file
first, then import — the operation completes in one non-interactive step:
# Write new values to a temp file (delete immediately after import)
sudo bash -c 'cat > /tmp/amulet-update.env' <<'EOF'
SECRET_KEY=new_value
ANOTHER_KEY=another_value
EOF
sudo amulet import \
--env-file /tmp/amulet-update.env \
--file /etc/amulet/secrets.vault \
< /etc/amulet/passphrase
sudo rm -f /tmp/amulet-update.env
sudo systemctl restart myapp
/tmpis world-readable on most Linux systems. Delete the temp file immediately after import.
Physical server: stronger option with TPM2
On bare-metal servers that have a TPM2 chip, LoadCredentialEncrypted binds
the passphrase to the TPM so the file is unreadable on any other machine.
Use the same stdin-based approach as step 3 to avoid shell history exposure:
sudo bash -c 'read -rs PASS && printf "%s" "$PASS" \
| systemd-creds encrypt --name=amulet-pass - /etc/amulet/passphrase.cred'
sudo chmod 600 /etc/amulet/passphrase.credChange the service unit:
LoadCredentialEncrypted=amulet-pass:/etc/amulet/passphrase.credVPS environments typically do not expose a TPM2 chip. Use plain
LoadCredentialon VPS.
Ubuntu 20.04 fallback
Ubuntu 20.04 ships systemd 245, which predates LoadCredential. This is a
last-resort option — upgrading to 22.04+ and using LoadCredential is
strongly preferred.
xargs -I{} is fragile when secrets contain spaces, quotes, or newlines. Use
this pattern only for single-line secrets such as API keys:
[Service]
ExecStart=/bin/sh -c 'cat /etc/amulet/passphrase \
| /usr/local/bin/amulet unseal API_KEY --file /etc/amulet/secrets.vault \
| xargs -I{} env API_KEY={} /opt/myapp/bin/myapp'Ubuntu 20.04 reached end-of-life in April 2025. Upgrading to 24.04 LTS is strongly recommended.
See also
- Deployment Guide — Locked vs Portable decision table, migration, Docker Compose
- Security Reference — vault format, crypto spec, threat model