Chapter 10: Security
Topic 64

Secrets Handling

SecretsRuntime

The naive Layer-A approach passed Driftwood's database password and SECRET_KEY as environment variables, read from the .env file from Chapter 4. That leaks. Environment values are visible in docker inspect, they bake into image layers visible through docker history, and they are inherited by every child process. Secrets belong in files mounted at run time — Docker and Compose secrets backed by tmpfs, or pulled from an external manager — never baked into an image and never committed to the repo.

This is where Driftwood's DB password stops being an environment variable and becomes a runtime secret. The app reads it from a file at /run/secrets/db_password instead of from DB_PASSWORD in its environment, and that one change pulls the credential out of inspect, out of the layers, and out of every child process's environment.

Why ENV and ARG Leak

Environment variables are not hidden. They show up verbatim in docker inspect on the container, they are inherited by every child process the app spawns, and they routinely land in crash logs and error reports. ARG and ENV values bake into image layers, so docker history reveals them too — the same lesson Chapter 4 topic 25 drew about configuration and the build-secret problem Chapter 5 solved. Anyone who can read the image or inspect the container reads the secret.

Two ways to deliver a password to a container
ENV / ARG
Wrong. Leaks via docker inspect and docker history, is inherited by every child process, and bakes into the image layers.
Mounted secret file
Right. Delivered as a tmpfs-backed file at /run/secrets/... the app reads — never in the environment, never in inspect, never on disk.

Never Bake Secrets Into Images

A secret added in a Dockerfile survives in the image even if a later layer "deletes" it. The layered filesystem keeps the original write; a RUN rm only adds a whiteout on top (Chapter 2 topic 07), and the secret is still recoverable from the earlier layer through docker history and a layer dump. Pushing that image publishes the secret to anyone who can pull it, and rotating it means rebuilding the image. Secrets must never enter the build context — the one exception being BuildKit build secrets (Chapter 5), which mount during a single RUN and never persist in a layer.

Runtime Secrets as Mounted Files

Docker and Compose secrets deliver a value as a file at /run/secrets/<name>, backed by tmpfs so it lives in memory — never on the writable layer, never in the environment. The app reads the file instead of an environment variable, which is why most images that support this expose a _FILE convention: you set DB_PASSWORD_FILE=/run/secrets/db_password and the app reads the path. That is how driftwood/web gets its DB password.

compose.yaml — DB password as a tmpfs-backed runtime secret
services:
  web:
    image: driftwood/web
    user: app
    read_only: true
    tmpfs:
      - /tmp
    environment:
      # point the app at the file, not the value
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    # the value lives in a file the repo never commits
    file: ./secrets/db_password.txt

Compose mounts db_password at /run/secrets/db_password on a tmpfs, and the app reads it from there because DB_PASSWORD_FILE points at the path. The value is never in the environment, never in docker inspect, and never in the image — and the secrets/ directory is in .gitignore, so it never reaches a commit.

tmpfs Keeps Secrets Off Disk

Mounting the secret on a tmpfs (in-memory) filesystem means it is never written to the container's layers or to a durable volume, and it disappears when the container stops. A stolen disk image yields nothing, because the secret was only ever in RAM. This is the difference between a Docker/Compose secret and a plain bind-mounted file: the bind mount leaves the secret sitting unencrypted on the host filesystem, while the secret mount keeps it tmpfs-backed.

External Secret Managers

For rotation, auditing, and central control, the value should not live in a file on the host at all. It lives in a manager — HashiCorp Vault, AWS or GCP Secrets Manager — and is fetched at startup or injected as a file by an agent. The container holds only a short-lived credential, and the source of truth, with rotation policy and access logs, sits outside any image or host. For a single-host Driftwood the Compose secret is enough; the manager is where you go when you need rotation and an audit trail across many hosts.

Common Mistakes
  • Passing the DB password as -e DB_PASSWORD=... or in the .env file — it shows in docker inspect, in child-process environments, and often in logs; the Layer-A .env is the naive version this topic replaces.
  • Baking a secret into the image via ARG/ENV and assuming a later RUN rm removes it — it persists in docker history and the earlier layer (Chapter 2 topic 07), and pushing the image publishes it.
  • Committing the .env file or a secrets file to the repo — version control keeps it forever in history even after deletion; secrets go in a manager or an ignored runtime file, never a commit.
  • Mounting a secret as a regular bind-mounted file on disk rather than tmpfs — it then lives on the host filesystem unencrypted; use Docker/Compose secrets so it stays tmpfs-backed.
Best Practices
  • Deliver runtime secrets as files via Docker/Compose secrets and read them from /run/secrets/... (the _FILE convention), not as environment variables, so they stay out of the environment and out of inspect.
  • Keep secrets tmpfs-backed and out of the image and the writable layer, so neither docker history nor a stolen disk image exposes them.
  • Use BuildKit --secret mounts (Chapter 5) for any secret needed only at build time, so it never lands in a layer.
  • Source production secrets from an external manager (Vault, a cloud secret store) for rotation and audit, handing the container only a short-lived credential.
Comparable tools Docker/Compose secrets · BuildKit --secret the in-engine runtime and build-time options HashiCorp Vault · AWS/GCP Secrets Manager external managers with rotation and audit; Kubernetes Secret objects fill the runtime-file role in a cluster gitleaks · git-secrets catch secrets before they reach a commit

Knowledge Check

Why is passing a secret as an environment variable a leak?

  • It is visible in docker inspect, inherited by child processes, and baked into layers seen via docker history
  • Environment variables are encrypted at rest, but the matching decryption key ships inside the very same image
  • They make the container start slowly enough that an attacker can race in and read the value from process memory
  • The kernel logs every environment variable to the host syslog by default, where any local user can grep it out

A secret was added in a Dockerfile and removed with a later RUN rm. Is it gone?

  • No — the original write survives in the earlier layer; the rm only adds a whiteout, so it is still recoverable
  • Yes — the rm reaches back and deletes the file from every earlier layer, so the secret is fully and permanently removed
  • Yes — Docker automatically squashes all the layers together on every build, quietly erasing the deleted secret
  • No, but pushing the image to a remote registry strips the secret out of the layers automatically on upload

How does a Docker/Compose secret reach the application?

  • As a tmpfs-backed file at /run/secrets/<name> that the app reads, often via a _FILE environment variable
  • As an environment variable injected only at runtime, late enough that docker inspect can no longer see its value
  • Baked into a dedicated encrypted image layer that the app reads and decrypts in memory at container startup
  • Fetched fresh over the network from the Docker daemon's secret store on every single read the app performs

Why mount the secret on tmpfs rather than as a plain bind-mounted file?

  • A tmpfs keeps the secret in memory only, so it never lands on disk and a stolen disk image yields nothing
  • A bind-mounted file is perfectly fine because the host filesystem transparently encrypts the secret at rest for you
  • A tmpfs simply lets the app read the secret measurably faster than it could from a regular on-disk file
  • A tmpfs makes the secret reliably persist across container restarts and reboots purely for operator convenience

You got correct