Rootless Deployment (user systemd)
This guide covers deploying Amulet under a non-root user service
(systemctl --user) — the right choice when your application already runs
rootless (e.g. rootless Podman, a user-owned process manager).
Use Ubuntu Production Deployment instead if your app runs as a dedicated system user under a root-owned service (
/etc/systemd/system). That guide usesLoadCredentialand is suitable for most server deployments.
When to use this guide
| Scenario | Guide to use |
|---|---|
App runs as a system user under /etc/systemd/system |
deploy-ubuntu.md |
| App runs rootless (rootless Podman, user process) | This guide |
| App runs in Docker / Podman with root daemon | deploy-ubuntu.md or Deployment Guide |
Mixing a root-owned service with rootless Podman containers commonly causes permission conflicts. If your containers run as a regular user, keep the systemd service in user space too.
Prerequisites
- Ubuntu 22.04+ (or any Linux with systemd 247+; user services work on older
versions too — only
LoadCredentialrequires 247+) amuletbinary installed — see Installation- Your application already runs correctly as the target user
1. Place the amulet binary
Install to the user's local bin directory so no sudo is needed:
curl -fL -o /tmp/amulet https://github.com/tsukasa-art/amulet/releases/latest/download/amulet-linux-x86_64
chmod +x /tmp/amulet
mkdir -p ~/.local/bin
install -m 0755 /tmp/amulet ~/.local/bin/amulet
~/.local/bin/amulet versionAdd ~/.local/bin to PATH if not already present:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrcUse the full path in scripts.
systemd --userservices run with a minimalPATHthat does not include~/.local/bin. Always write/home/youruser/.local/bin/amulet(or$HOMEequivalent) in wrapper scripts, never justamulet.
2. Create the vault from your .env file
mkdir -p ~/.config/amulet
amulet import \
--env-file /path/to/your/.env \
--file ~/.config/amulet/secrets.vault
chmod 600 ~/.config/amulet/secrets.vaultVerify the import:
amulet list --file ~/.config/amulet/secrets.vault
.envformat requirement:importexpects plainKEY=VALUElines. Lines starting withexport(e.g.export API_KEY=foo) are not supported — remove theexportprefix before importing.
After confirming the vault is correct, wipe the plaintext:
# Option A: wipe in place (replaces values with blank lines)
amulet import --env-file /path/to/your/.env \
--file ~/.config/amulet/secrets.vault --wipe
# Option B: delete the file entirely
rm /path/to/your/.env3. Store the passphrase
Write the passphrase to a user-only file. printf "%s" writes no trailing
newline, which avoids a mismatch when amulet unseal reads the file:
bash -c 'read -rsp "Amulet passphrase: " PASS; echo; printf "%s" "$PASS" > ~/.config/amulet/passphrase'
chmod 600 ~/.config/amulet/passphraseConfirm the file looks right (should be one line with no trailing newline):
wc -c ~/.config/amulet/passphrase4. Consolidate to one vault location
If a secrets.vault exists elsewhere in the project directory, retire it so
there is exactly one canonical file:
# Move project-local copy to a backup name; the service will use ~/.config/amulet/secrets.vault
mv ~/myapp/secrets.vault ~/myapp/secrets.vault.bakHaving two vault files with the same name in different directories is a common source of confusion — updates applied to one leave the other stale.
5. Create a startup wrapper
The wrapper unseals all secrets and exports them before launching the app.
Use absolute paths throughout — user services inherit a minimal PATH.
cat > ~/.local/bin/myapp-start.sh <<'EOF'
#!/bin/bash
set -euo pipefail
VAULT="$HOME/.config/amulet/secrets.vault"
PASSPHRASE_FILE="$HOME/.config/amulet/passphrase"
# Unseal all keys and export them
while IFS= read -r key; do
value="$(cat "$PASSPHRASE_FILE" | /home/youruser/.local/bin/amulet unseal "$key" --file "$VAULT")"
export "$key=$value"
done < <(/home/youruser/.local/bin/amulet list --file "$VAULT")
# Launch the application
exec /path/to/your/app
EOF
chmod 750 ~/.local/bin/myapp-start.shReplace /home/youruser with the actual home directory path (not ~ or
$HOME, since ExecStart= in systemd does not expand those).
6. Create the user service
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/myapp.service <<'EOF'
[Unit]
Description=My App (rootless)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/home/youruser/myapp
ExecStart=/home/youruser/.local/bin/myapp-start.sh
[Install]
WantedBy=default.target
EOFLoad and start:
systemctl --user daemon-reload
systemctl --user enable myapp
systemctl --user start myapp
systemctl --user status myapp --no-pager -l7. Enable linger (auto-start on boot)
User services only start automatically after login by default. Enable linger so the service starts at boot even when no one is logged in:
loginctl enable-linger "$USER"Confirm:
loginctl show-user "$USER" | grep Linger
# Expected: Linger=yesTest by rebooting and checking the application is up before you log in.
8. Verify
systemctl --user status myapp
# confirm your app is reachable, e.g.:
curl -fsS http://127.0.0.1/your-health-endpointUpdating secrets after deployment
Normal update (interactive)
echo -n "new_secret_value" | \
amulet seal SECRET_KEY --file ~/.config/amulet/secrets.vault
systemctl --user restart myappWhen SSH may disconnect (bulk update via temp file)
Interactive seal over SSH can be interrupted mid-input if the connection
drops. For bulk updates or long-running operations, use a temp file instead:
# Write new values to a temp file (never commit this file)
cat > /tmp/amulet-update.env <<'EOF'
SECRET_KEY=new_value
ANOTHER_KEY=another_value
EOF
amulet import \
--env-file /tmp/amulet-update.env \
--file ~/.config/amulet/secrets.vault \
< ~/.config/amulet/passphrase
rm -f /tmp/amulet-update.env
systemctl --user restart myappDelete
/tmp/amulet-update.envimmediately after import./tmpis world-readable by default on most Linux systems.
Security summary
| Control | Effect |
|---|---|
chmod 600 on vault and passphrase |
Only the owning user can read them |
Full path to amulet in scripts |
Immune to PATH manipulation |
loginctl enable-linger |
Service starts at boot; does not require interactive login |
| Locked vault (default) | Vault is bound to this machine's machine_id; unreadable on another host |
Single canonical vault path (~/.config/amulet/) |
No stale copies diverging silently |
/proc/<pid>/environ note |
Root on the same host can read exported env vars — host-level access control is still required |
See also
- Ubuntu Production Deployment — root service with
LoadCredential(system user, no rootless requirement) - Deployment Guide — Locked vs Portable decision table, migration, Docker Compose
- Security Reference — vault format, crypto spec, threat model