ZB Field Notes

Docker secrets: the delivery channel is the whole game

The source is plumbing; the channel is the threat model

I spent an evening building a tiny Docker Compose playground to settle an argument I keep having with myself: where should a container's secrets actually come from? Five worked examples later, I'm convinced the question is usually framed wrong. Where a value comes from — a literal in the compose file, a .env, a .txt, a host environment variable — is just plumbing. What decides who can read your secret back out is the delivery channel: an environment variable, or a file. In regulated banking work, that distinction is the gap between a clean audit and an incident review.

First, a container that actually stays up

A container lives exactly as long as its PID 1. The moment the foreground process returns, the container exits. So “long-lived” isn't a setting — it's just a process that never finishes:

services:
  app:
    image: alpine:3.20
    command: >
      sh -c 'while true; do echo alive; sleep 10; done'
    restart: unless-stopped

Inside the container that loop reports pid=1 — it is init. restart: unless-stopped brings it back after a crash or a host reboot, but respects a deliberate docker compose stop.

The anti-pattern, and proving it leaks

The way everyone starts is the worst one: the secret as an inline environment variable.

environment:
  DB_PASSWORD: "hunter2-inline-secret"

It works, and that's the trap. The value is now committed to git forever, and at runtime it's readable by anyone who can poke the container. I didn't take that on faith — I asked Docker:

docker inspect env-inline-app --format '{{json .Config.Env}}'
# ["DB_PASSWORD=hunter2-inline-secret","PATH=/usr/local/sbin:..."]

docker compose exec app env | grep DB_PASSWORD
# DB_PASSWORD=hunter2-inline-secret

Plaintext, twice. Environment variables also get inherited by every child process and have a habit of surfacing in logs and stack traces. For anything sensitive, that's disqualifying.

.env vs env_file: a hygiene win, not an isolation win

The next move people make is shifting the value into a file — and here's the gotcha that trips up almost everyone: .env and env_file: are different mechanisms.

  • .env is read by Compose itself for ${VAR} substitution inside the YAML — on the host, at parse time. It is not automatically injected into the container.
  • env_file: points at a file whose KEY=VALUE lines are loaded into the container's environment at runtime.

Both keep the secret out of the compose file — a genuine source-control win — but the value still lands as a runtime environment variable, with the same docker inspect exposure as before. You've improved git hygiene, not runtime isolation. Fine for non-secret config; not fine for a database password.

Secrets as files: /run/secrets and a clean environment

This is the one I'd reach for. A Compose secret is delivered as a read-only file at /run/secrets/<name> — backed by an in-memory tmpfs on Linux — instead of an environment variable.

services:
  app:
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt

Same proof commands, opposite result:

docker compose exec app env | grep -i pass    # nothing

docker inspect secrets-file-app --format '{{json .Config.Env}}'
# ["PATH=/usr/local/sbin:..."]   <- only PATH, no secret

The cost is that your app has to read a file instead of an env var — which is exactly the convention behind the *_FILE variables in official images like POSTGRES_PASSWORD_FILE. A cheap price for a container whose environment no longer betrays you.

Sourcing from the host env, delivering as a file

CI/CD systems and secret managers love to hand you environment variables. You can take that convenience without the in-container exposure: source the secret from a host env var, but let Compose deliver it as a file.

secrets:
  db_password:
    environment: DB_PASSWORD

The value rides in on a host variable; inside the container it's a file at /run/secrets/db_password, and $DB_PASSWORD is unset. Best of both worlds for a pipeline — the plumbing is env-var-shaped, the runtime exposure is file-shaped.

An escaping gotcha worth a scar

Compose runs its own $ interpolation, collapsing $$ to a single $ before the shell ever sees it. So a one-liner that wants the shell's PID ($$) has to be written with four:

# in a compose `command`, to print the shell PID:
echo "pid=$$$$"   # YAML $$$$ -> $$ -> shell expands to the real pid

That one cost me a confused minute when pid=$$ rendered as a bare $.

Where Compose stops and Vault begins

One honest caveat: even with file-based secrets, the value still sits in plaintext on the host — in secrets/*.txt or a host env var. Compose secrets protect the container, not the host. That's precisely the line where production reaches for Docker Swarm secrets (encrypted in the Raft log, distributed over mTLS), Kubernetes Secrets paired with external-secrets, or HashiCorp Vault with a sidecar that fetches at runtime so nothing sensitive touches disk. The useful part: all of them speak the same /run/secrets/ file interface. Build the file habit now and you're already holding the shape production wants.