One-Time Secrets in Docker Swarm Using FIFO Pipes
Docker Swarm mounts secrets to /run/secrets/ as read-only tmpfs volumes. Anyone with docker exec access can read them anytime. The fix: use named pipes (FIFOs) for one-time secret delivery at container startup.
Problems with the Standard Approach
Secrets are stored in the Raft log on manager nodes, sent via mTLS to workers, and mounted into containers. Here's how it's defined in docker-compose.yml:
version: "3.8"
services:
app:
image: myapp:latest
secrets:
- db_password
- api_key
secrets:
db_password:
external: true
api_key:
external: true
Reading in your app:
with open("/run/secrets/db_password") as f:
db_password = f.read().strip()
Access via docker exec:
$ docker exec -it <container_id> cat /run/secrets/db_password
SuperSecretPassword123
Access on the host:
sudo cat /var/lib/docker/containers/<container_id>/mounts/secrets/ioz4pc6q8fjsfhem4pwsr35u6
SuperSecretPassword123
Why Standard Workarounds Fail
- rm /truncate/chmod: read-only tmpfs
- umount: requires CAP_SYS_ADMIN
- Environment variables: visible in
/proc/1/environ, inherited by child processes - External managers (Vault): adds extra infrastructure
How FIFO Pipes Work
Named pipes have these key traits:
- Writes block without a reader
- Reads block without a writer
- Data is consumed on first read
- Subsequent reads hang
Demo:
$ mkfifo /tmp/my_pipe
echo "secret_value" > /tmp/my_pipe # blocks
cat /tmp/my_pipe # secret_value, data gone
cat /tmp/my_pipe # hangs
Solution Architecture
Two containers share a tmpfs volume with FIFOs:
- secret-injector: creates FIFOs, writes secrets, then exits
- app: reads from FIFOs once, keeps secrets in memory only
The app container has no /run/secrets/.
Implementation
docker-compose.yml
version: "3.8"
services:
secret-injector:
image: alpine:3.19
secrets:
- db_password
- api_key
volumes:
- secrets_pipe:/shared
entrypoint: ["sh", "-c", "
mkfifo /shared/db_password /shared/api_key &&
cat /run/secrets/db_password > /shared/db_password &
cat /run/secrets/api_key > /shared/api_key &
wait
"]
deploy:
restart_policy:
condition: none
resources:
limits:
memory: 16M
app:
image: myapp:latest
volumes:
- secrets_pipe:/shared:ro
entrypoint: ["sh", "-c", "
DB_PASSWORD=$(cat /shared/db_password) &&
API_KEY=$(cat /shared/api_key) &&
exec myapp
"]
depends_on:
- secret-injector
volumes:
secrets_pipe:
driver: local
driver_opts:
type: tmpfs
device: tmpfs
o: size=1m,noexec,nosuid
secrets:
db_password:
external: true
api_key:
external: true
Execution Sequence
- secret-injector:
- mkfifo /shared/db_password /shared/api_key
- cat /run/secrets/... > /shared/... (blocks)
- wait keeps container alive
- app:
- DB_PASSWORD=$(cat /shared/db_password) → unblocks writer
- Data consumed from FIFO
- secret-injector exits (
restart_policy: none)
- app runs with secrets in memory
What an Attacker Sees
In app:
$ docker exec -it <app_container> sh
$ ls /run/secrets/
ls: /run/secrets/: No such file or directory
$ cat /shared/db_password # hangs
^C
In secret-injector:
$ docker exec -it <injector_container> sh
Error: container is not running
Hardening: Use File Descriptors
Skip environment variables. Pass via file descriptors (FDs):
Entrypoint:
#!/bin/sh
exec 3< /shared/db_password
exec 4< /shared/api_key
exec myapp --db-password-fd=3 --api-key-fd=4
Python:
import os
def read_secret_from_fd(fd_num: int) -> str:
with os.fdopen(fd_num, 'r') as f:
return f.read().strip()
db_password = read_secret_from_fd(3)
api_key = read_secret_from_fd(4)
Go:
package main
import (
"fmt"
"io"
"os"
)
func readSecretFromFD(fd int) (string, error) {
f := os.NewFile(uintptr(fd), fmt.Sprintf("fd-%d", fd))
if f == nil {
return "", fmt.Errorf("invalid file descriptor: %d", fd)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(data), nil
}
Secrets live only in process memory.
Key Takeaways
- Secrets read once via FIFO, data consumed
secret-injectorexits, nodocker execpossible- App has no
/run/secrets/ - Prefer file descriptors over env vars
- No external services needed
— Editorial Team
No comments yet.