Stop Using .env for Linux Services: Safer Secrets with systemd Credentials
If your Linux service still loads API keys from Environment= or .env, you're carrying avoidable risk.
Environment variables are convenient, but they’re not designed as a secure secret-delivery mechanism. Linux exposes a process’s initial environment via /proc/<pid>/environ (subject to permissions), and environment values can spread through child processes.
systemd credentials give you a better pattern:
- Secret material is delivered as files in a runtime credential directory
- Access is scoped to the service
- You can pass encrypted credentials with
systemd-creds - Your unit no longer hardcodes cleartext secrets
This guide is a full, practical migration.
Why move away from .env for secrets?
From Linux proc_pid_environ(5), /proc/<pid>/environ contains the initial environment passed at exec time. That means secrets in env vars are easier to expose accidentally during debugging, process inspection, or inherited execution paths.
systemd credentials are explicitly designed for sensitive data delivery to services.
Prerequisites
- A Linux host with systemd (check with
systemctl --version) -
systemd-credsavailable (usually packaged with systemd) - Root/sudo access
Check:
systemctl --version
systemd-creds --version
Step 1) Create a demo service user + app directory
sudo useradd --system --home /opt/demo-secrets --shell /usr/sbin/nologin demo-secrets || true
sudo install -d -o demo-secrets -g demo-secrets /opt/demo-secrets
Create a minimal script that reads a credential file path passed by systemd:
sudo tee /opt/demo-secrets/app.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# systemd will place credential files under $CREDENTIALS_DIRECTORY
TOKEN_FILE="${CREDENTIALS_DIRECTORY:?missing}/api-token"
if [[ ! -f "$TOKEN_FILE" ]]; then
echo "Token file missing: $TOKEN_FILE" >&2
exit 1
fi
# Demo output: only show length, never print secret
TOKEN_LEN=$(wc -c < "$TOKEN_FILE")
echo "Credential loaded successfully (bytes=$TOKEN_LEN)"
EOF
sudo chown demo-secrets:demo-secrets /opt/demo-secrets/app.sh
sudo chmod 0750 /opt/demo-secrets/app.sh
Step 2) Store secret outside the unit file
Create a plaintext secret file (for initial migration):
sudo install -d -m 0750 /etc/demo-secrets
printf '%s' 'replace-with-real-token' | sudo tee /etc/demo-secrets/api-token >/dev/null
sudo chmod 0640 /etc/demo-secrets/api-token
sudo chown root:root /etc/demo-secrets/api-token
Step 3) Define service with LoadCredential=
sudo tee /etc/systemd/system/demo-secrets.service >/dev/null <<'EOF'
[Unit]
Description=Demo service using systemd credentials
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=demo-secrets
Group=demo-secrets
ExecStart=/opt/demo-secrets/app.sh
# credential-id:source-path
LoadCredential=api-token:/etc/demo-secrets/api-token
# Basic hardening
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
[Install]
WantedBy=multi-user.target
EOF
Reload + run:
sudo systemctl daemon-reload
sudo systemctl start demo-secrets.service
sudo systemctl status --no-pager demo-secrets.service
Check logs:
journalctl -u demo-secrets.service --no-pager -n 20
You should see Credential loaded successfully (...).
Step 4) Verify credential location and behavior
systemd exposes credentials via a runtime directory ($CREDENTIALS_DIRECTORY) for the service. Your app reads files from there (not environment variables).
To inspect within an interactive transient unit:
sudo systemd-run --wait --pipe \
-p LoadCredential=api-token:/etc/demo-secrets/api-token \
/bin/sh -lc 'echo "$CREDENTIALS_DIRECTORY"; ls -l "$CREDENTIALS_DIRECTORY"; wc -c "$CREDENTIALS_DIRECTORY/api-token"'
Step 5) Encrypt credentials at rest with systemd-creds
Instead of keeping plaintext secret files, encrypt them for host-bound usage.
printf '%s' 'replace-with-real-token' | sudo systemd-creds encrypt - /etc/demo-secrets/api-token.cred
sudo chmod 0640 /etc/demo-secrets/api-token.cred
sudo chown root:root /etc/demo-secrets/api-token.cred
Update the service to use encrypted input:
LoadCredentialEncrypted=api-token:/etc/demo-secrets/api-token.cred
Apply change:
sudo systemctl daemon-reload
sudo systemctl restart demo-secrets.service
sudo systemctl status --no-pager demo-secrets.service
Step 6) Rotate secret safely
When rotating, write new value, encrypt, and restart the unit:
printf '%s' 'new-rotated-token' | sudo systemd-creds encrypt - /etc/demo-secrets/api-token.cred
sudo systemctl restart demo-secrets.service
For production rollouts, pair this with a maintenance window or health check + rollback flow.
Migration checklist (real services)
- [ ] Remove secret values from
Environment=and.envfiles - [ ] Move secret inputs to
LoadCredential=/LoadCredentialEncrypted= - [ ] Update app code to read from
$CREDENTIALS_DIRECTORY/<id> - [ ] Ensure logs never print secret values
- [ ] Restrict service permissions (
NoNewPrivileges,ProtectSystem, etc.) - [ ] Document rotation runbook
Common gotchas
-
Wrong credential ID/file mismatch
-
LoadCredential=name:pathmust match app filename under$CREDENTIALS_DIRECTORY/name.
-
-
App still expects env vars
- Add a small startup shim that reads credential file and exports internally only if absolutely necessary.
-
Permissions confusion
- Source file readability is handled by systemd at start, then projected into credential dir for the service.
-
Printing secrets while debugging
- Never
catsecret values in journals. Log hashes/lengths only.
- Never
Final thought
This is one of those upgrades that reduces risk without adding operational pain. Once you switch to systemd credentials, secret handling becomes explicit, auditable, and less fragile than .env-driven service configs.
If you’re already using systemd units in production, this is low-effort, high-impact hardening.
References
- systemd credential docs: https://systemd.io/CREDENTIALS/
-
systemd.exec(5)(LoadCredential=,LoadCredentialEncrypted=): https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html -
systemd-creds(1): https://www.freedesktop.org/software/systemd/man/latest/systemd-creds.html - Linux
/proc/<pid>/environsemantics (proc_pid_environ(5)): https://man7.org/linux/man-pages/man5/proc_pid_environ.5.html
Top comments (0)