2026-07-24
Encrypted systemd credentials as Docker Compose secret sources
Pass a host-bound credential through systemd into one Compose service, then verify access, rotation, cleanup, and mount namespace failure.
This pattern keeps secret plaintext out of the Compose project, .env files, and container environment. systemd decrypts a credential when the unit activates, makes an immutable file available below $CREDENTIALS_DIRECTORY, and releases it when the unit deactivates. %d in a unit expands to that credential directory. systemd credentials and systemd.exec document that lifecycle.
The boundary is narrow. Compose gets a host file and grants it to one service as /run/secrets/app-secret; the application reads that file. This does not make Docker access unprivileged. Access to the Docker socket or docker group is effectively host-root-equivalent in practical terms, because it can control containers with host access. Docker's daemon attack-surface guidance describes that boundary. Likewise, ciphertext plus the host key does not protect against a host compromise.
Tested boundary
The pilot passed on systemd 259, Docker Engine 29.6.1, Docker Compose 5.3.1, and alpine:3.23. Older and newer releases were not tested. Check the installed systemd and Compose manuals before using this, especially the ownership behavior of Compose file secrets.
Encrypt and store the credential
Create the plaintext only long enough to encrypt it, then keep the encrypted credential outside the Compose project. This example reads the value from standard input and writes a root-owned ciphertext file:
sudo install -d -m 0700 /etc/systemd/credentials
sudo systemd-ask-password --echo=no -n \
'Enter app-secret without a trailing newline' \
| sudo systemd-creds encrypt --name=app-secret - \
/etc/systemd/credentials/app-secret.cred
sudo chmod 0600 /etc/systemd/credentials/app-secret.credsystemd-creds encrypt authenticates ciphertext with AES256-GCM and embeds the credential name. Depending on what the host supports, it selects host-key, TPM2, or combined key material. LoadCredentialEncrypted= decrypts and authenticates the result at activation, as documented in systemd.exec.
In the pilot, encryption warned that /var/lib/systemd/credential.secret was not on encrypted media. Treat that as a recovery and storage issue, not a harmless warning. Plan backups and recovery for host-key loss, TPM replacement, and TPM policy changes before deploying. Do not weaken file permissions or make secret material world-readable to simplify recovery.
Keep systemd responsible for the Compose lifecycle
Put the Compose project in /srv/credential-pilot and save this unit as /etc/systemd/system/systemd-compose-credential-pilot.service:
[Unit]
Description=Compose credential pilot
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
User=1000
SupplementaryGroups=docker
WorkingDirectory=/srv/credential-pilot
LoadCredentialEncrypted=app-secret:/etc/systemd/credentials/app-secret.cred
ExecStart=/usr/bin/docker compose up -d --wait
ExecStop=/usr/bin/docker compose down
ExecStopPost=-/usr/bin/docker compose down --remove-orphans
RemainAfterExit=yes
[Install]
WantedBy=multi-user.targetType=oneshot runs the Compose startup command and exits. RemainAfterExit=yes leaves the unit active, so systemd continues to own the credential lifecycle and will run ExecStop on shutdown. ExecStop is not enough for a failed ExecStart, because the unit may never become active and systemd then skips it. ExecStopPost runs after that failed start as well; repeating docker compose down --remove-orphans is idempotent, and the - prefix tolerates the no-project case. See RemainAfterExit=. The service account still has privileged Docker access through its supplementary group; do not treat the UID as an isolation boundary.
Grant the file to one service
Use the systemd credential path only as a top-level Compose file secret source. This Compose file grants it only to app:
services:
app:
image: alpine:3.23
user: "1000:1000"
command: ["sh", "-c", "while :; do sleep 3600; done"]
secrets:
- app-secret
healthcheck:
test: ["CMD-SHELL", "test -r /run/secrets/app-secret && cat /run/secrets/app-secret >/dev/null"]
interval: 5s
timeout: 2s
retries: 3
secrets:
app-secret:
file: "${CREDENTIALS_DIRECTORY}/app-secret"Compose mounts an explicitly granted secret at /run/secrets/<name>, rather than putting its value in the service environment. Docker's Compose secrets guide covers that model. The pilot succeeded because both the host service and container used numeric UID 1000. That observation applies to the tested rootful daemon without user-namespace remapping; validate the relevant UID mapping separately on a remapped or rootless daemon. Match the numeric UID that needs to read the bind-mounted file, not just a user name.
For file-backed secrets, Compose silently ignores uid, gid, and mode; those attributes apply to environment-sourced secrets. The Compose secrets reference calls this out. Verify permissions inside the real container instead of assuming the YAML can fix them. The healthcheck deliberately reads the secret, making docker compose up -d --wait depend on usable secret access.
Start and verify without printing the value
sudo systemctl daemon-reload
sudo systemctl start systemd-compose-credential-pilot.service
container_id="$(sudo /usr/bin/docker compose --project-directory /srv/credential-pilot ps -q app)"
sudo /usr/bin/docker inspect "$container_id" --format '{{range .Mounts}}{{if eq .Destination "/run/secrets/app-secret"}}{{.Source}}{{end}}{{end}}'
sudo /usr/bin/docker compose --project-directory /srv/credential-pilot exec -T app sh -c 'id -u; test -r /run/secrets/app-secret; cat /run/secrets/app-secret >/dev/null'
before_hash="$(sudo /usr/bin/docker compose --project-directory /srv/credential-pilot exec -T app sha256sum /run/secrets/app-secret | awk '{print $1}')"
sudo /usr/bin/docker compose --project-directory /srv/credential-pilot psThe first command derives the container ID from the Compose project and shows the resolved bind source but not the secret. The second proves access as the application UID without sending the value to the terminal. before_hash retains the initial digest without publishing it. Also confirm the service is healthy. A running container alone is insufficient, because the secret mount can exist but be unreadable.
Rotate by replacing ciphertext, then restarting
An active systemd credential is immutable. Replacing the .cred file changes nothing in an already active unit. Encrypt the replacement into a temporary file, atomically replace the ciphertext, and restart the unit:
sudo systemd-ask-password --echo=no -n \
'Enter rotated app-secret without a trailing newline' \
| sudo systemd-creds encrypt --name=app-secret - \
/etc/systemd/credentials/app-secret.cred.new
sudo chmod 0600 /etc/systemd/credentials/app-secret.cred.new
sudo mv /etc/systemd/credentials/app-secret.cred.new \
/etc/systemd/credentials/app-secret.cred
sudo systemctl restart systemd-compose-credential-pilot.service
after_hash="$(sudo /usr/bin/docker compose --project-directory /srv/credential-pilot exec -T app sha256sum /run/secrets/app-secret | awk '{print $1}')"
expected_after_hash="$(sudo systemd-ask-password --echo=no 'Enter expected rotated SHA-256 from the separately protected rotation record')"
test "$after_hash" != "$before_hash"
test "$after_hash" = "$expected_after_hash"
sudo /usr/bin/docker compose --project-directory /srv/credential-pilot psCapture the before and after digests in shell variables, require that rotation changed the value, then compare the after digest with an expected digest retrieved from a separately protected rotation record. None of those commands prints the secret or a published synthetic digest.
Stop and check cleanup
Stopping the unit runs Compose teardown and deactivates the credential:
sudo systemctl stop systemd-compose-credential-pilot.service
sudo /usr/bin/docker ps --filter name=credential-pilot --format '{{.Names}}'
sudo test ! -e /run/credentials/systemd-compose-credential-pilot.serviceThe pilot left zero pilot containers and removed the runtime credential directory. Check both conditions after every stop, because Compose cleanup and systemd credential cleanup are separate observations.
Do not hide the mount namespace failure
PrivateMounts=yes, or another setting that implies a private mount namespace, can break this pattern. The failure pilot used a long-running container without the secret-reading healthcheck, so docker compose up -d --wait reported success while the container kept running. Its bind source was /run/credentials/systemd-compose-credential-pilot.service/app-secret, yet this failed inside the container:
test -r /run/secrets/app-secretThe shown healthcheck is the corrective startup gate: it reads the actual secret, so docker compose up -d --wait should fail rather than report a successful startup when this mount-namespace breakage makes the secret unreadable. Review mount-namespace options on the systemd unit and require healthy status.
Verification checklist
- Confirm the secret is granted only to the intended Compose service.
- Confirm the application numeric UID can read
/run/secrets/app-secretinside the running container. - Confirm the healthcheck is healthy and reads the secret.
- Capture before and after in-container SHA-256 values, require inequality, and compare the after value with a separately protected expected value.
- Rotate by atomically replacing ciphertext and restarting the systemd unit.
- Stop the unit and confirm both zero pilot containers and removal of its credential directory.
- Review
PrivateMounts=and options that create a private mount namespace. - Treat Docker socket or group access as privileged host access.
- Maintain a tested recovery plan for the host key, TPM hardware, and TPM policy changes.