Docker Compose Secrets: Keep Passwords Out of the YAML
You've already done the sensible thing: your database password lives in a git-ignored .env file, not hardcoded in compose.yaml. That solves the "don't commit passwords to GitHub" problem. It doesn't solve the next one. Docker compose secrets are the mechanism for that next step, and the Docker docs now tell readers to use them for anything sensitive. What they don't always explain is what actually happens on disk when you do, which is the part that trips people up.
This article picks up exactly where the interpolation story leaves off. If you haven't set up .env and ${POSTGRES_PASSWORD} variables yet, read the environment variables guide first, since it covers the "Read secrets from .env" checkbox that moves passwords into ${VAR} references for you in one click. From here on, we're one layer deeper: the secrets: block, what the container sees at runtime, and the build-time version of the same problem in a Dockerfile.
What a Compose Secret Actually Is Without Swarm
Here's the thing that clears up almost every confused forum post on this topic: on a single host, without Swarm, a Compose secret is a read-only file. That's it. No encryption, no vault, no key management. Compose takes the file you point it at and bind-mounts it into the container at /run/secrets/<secret_name>, read-only, and that's the entire mechanism. A moderator on the Docker forums put it plainly: a Compose-only secret is "more or less the same as binding a read-only file into the container." Compare that to Swarm mode, where secrets are stored encrypted in Swarm's Raft log and delivered to containers over an in-memory filesystem. Compose alone does neither of those things.
Worth knowing: this isn't a limitation someone added on purpose. Docker/Compose issue #7626 shows that file-based secrets already worked without Swarm back in Compose 1.25, in 2020, years before the docs admitted it. The behavior existed first; the documentation caught up later. So if you've read that "secrets need Swarm," that's outdated, and if secrets: isn't working for you on a plain single-host setup, the cause is almost never the Swarm requirement. It's usually a YAML indentation issue or an old Compose v1 binary.
One more constraint worth flagging early: this is a Linux-container feature. Docker's own how-to notes that Compose delivers each secret by bind-mounting a single file, and Windows containers only support bind-mounting directories, not individual files. If you're running Windows containers, this whole mechanism isn't available to you the same way.
A Working Example: Postgres With a Password File
Let's make this concrete. Say you're running Postgres and you don't want POSTGRES_PASSWORD sitting in plain text anywhere Compose can print it back to you. First, put the password in its own file, outside your repo's tracked files:
mkdir -p secrets
echo "correcthorsebatterystaple" > secrets/db_password.txt
echo "secrets/" >> .gitignore
Then wire it into compose.yaml. There are two parts: a top-level secrets: block that tells Compose where the file lives, and a per-service secrets: list that grants that specific service access to it.
services:
db:
image: postgres:16
environment:
POSTGRES_USER: appuser
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
POSTGRES_DB: appdb
secrets:
- db_password
volumes:
- db-data:/var/lib/postgresql/data
secrets:
db_password:
file: ./secrets/db_password.txt
volumes:
db-data:
Two things happen here that are easy to miss. First, the top-level secrets: entry only declares the secret exists and where its contents come from; a service gets no access to it until you also list it under that service's own secrets: key. The Compose Specification is explicit about this: services can only access secrets when explicitly granted within their own service block. Second, notice the variable name: POSTGRES_PASSWORD_FILE, not POSTGRES_PASSWORD. That trailing _FILE is doing real work, and it's the next thing worth understanding properly.
One caveat that catches people during a database upgrade: the official Postgres image only applies POSTGRES_PASSWORD_FILE when the data directory is empty, meaning first initialization. If you rotate the secret file later on a database that's already running, the running database's password doesn't change. You'd need to change it through SQL, then update the file to match.
The _FILE Convention: Which Images Actually Support It
Mounting the file is only step one. The container has that file sitting at /run/secrets/db_password, but nothing reads it automatically. The application inside still has to know to look there, and that's a convention each image's entrypoint script implements on its own, not something Docker provides for free. Miss this, and you'll set a secret, run the container, and get a login failure anyway because the app is still checking an empty POSTGRES_PASSWORD variable that was never set.
The _FILE suffix pattern started with the Postgres image specifically because people could see their database password sitting in docker inspect output. The original 2016 feature request on the postgres image's GitHub tracker says exactly that. Here's how the convention plays out across the images people actually use:
| Image | _FILE support | Notes |
|---|---|---|
| postgres | POSTGRES_PASSWORD_FILE, POSTGRES_USER_FILE, POSTGRES_DB_FILE | Applied only on first init of an empty data directory |
| mysql / mariadb | MYSQL_ROOT_PASSWORD_FILE, MYSQL_PASSWORD_FILE | Same pattern, same first-init limitation |
| wordpress | WORDPRESS_DB_PASSWORD_FILE, plus the DB host, user, name, table prefix, and all eight key/salt values | The widest _FILE coverage of any commonly used image; see the WordPress image's documented env vars for the full list |
| pi-hole | WEBPASSWORD_FILE | Expects a bare name, not the full /run/secrets/ path (the quirk that catches people, explained below) |
WordPress's _FILE coverage is the widest of any commonly used image, spanning the database password, host, user, name, table prefix, and all eight authentication keys and salts. If you're setting up that stack from scratch, the WordPress with Docker Compose guide walks through a working compose.yaml alongside MySQL, and the secrets pattern from this article layers directly on top of it once the base stack runs.
That Pi-hole row deserves a story, because it's a good illustration of how a working setup can still fail for a reason that has nothing to do with Compose. A user on the Pi-hole Discourse forum set WEBPASSWORD_FILE=/run/secrets/pihole_password, following the same pattern that works for Postgres, MySQL, and WordPress, and got locked out. Pi-hole's entrypoint expects just the file name, pihole_password, and prepends /run/secrets/ itself. Set the full path and it looks in the wrong place. The secret file was there, the mount was correct, and none of that mattered because the two conventions disagree. There's no general rule that covers every image here. Read the specific README before you assume.
For images that don't support _FILE at all, the community fallback is a small wrapper: override the entrypoint or command to read the file into an environment variable right before starting the real process.
command: >
sh -c 'export API_KEY="$(cat /run/secrets/api_key)" && exec node server.js'
It's not elegant, but it keeps the secret out of the YAML and out of your shell history, which is the point.
Docker Compose Secrets vs Environment Variables
People often treat secrets and environment variables as two ways of doing the same thing. They're not. They sit at different layers, and mixing them up is where most of the "is this even working" confusion comes from. Docker's own documentation explains the reasoning plainly: environment variables are often visible to every process in the container, can leak into logs when something crashes, and get printed back to you by tooling without much effort. A docker compose password sitting in environment: is one crash log away from being in your error tracker.
| Where the value lives | Environment variable | File-sourced secret |
|---|---|---|
| Storage | Process environment inside the container | Read-only file at /run/secrets/<name> |
Visible in docker inspect | Yes, in Config.Env | No, only the mount path shows, not the content |
Visible in docker compose config | Yes, resolved and printed | No |
| Appears in crash logs / env dumps | Often, yes | Only if the app deliberately logs the file's contents |
| Per-service access control | Whatever environment: you set per service | Explicit grant required via each service's own secrets: list |
| Needs image support | No, any image reads its own env vars | Yes, the entrypoint must implement _FILE or an equivalent |
None of this encrypts anything. That's worth saying directly, because "secret" implies more protection than the mechanism actually delivers on a single host. Anyone who can run docker exec into the container, or read the host file directly, can still see the value. What the secrets mechanism buys you is narrower than encryption: the value stops appearing in places you didn't intend, like inspect output, config dumps, and accidental log lines. That's a real improvement. It's just not the improvement the word "secret" suggests.
What docker inspect Actually Shows
This is the difference you can verify yourself in under a minute, and it's the clearest way to see why the mechanism exists. Run a container with a plain environment-variable password, then inspect it:
docker inspect --format '{{.Config.Env}}' my-postgres-env
That command prints the full environment array, password included, in plain text. Anyone with access to the Docker socket on that host, or to your CI logs if this command gets pasted into a pipeline, sees the value. Now run the same inspection against the container using a file-sourced secret instead:
docker inspect --format '{{.Config.Env}}' my-postgres-secret
docker inspect --format '{{json .Mounts}}' my-postgres-secret
The Env output no longer contains the password (only POSTGRES_PASSWORD_FILE=/run/secrets/db_password, a path, not a value). The Mounts output shows a bind mount with a Destination of /run/secrets/db_password and the host path the secret file lives at, but not what's inside that file. That's the entire practical benefit in one comparison: the path is visible, the contents aren't.
Keep the limits honest, though. If someone can run docker exec my-postgres-secret cat /run/secrets/db_password, they read the value just fine. The secret isn't hidden from anyone with shell access to the running container, or from anyone with read access to the host file itself. What it stops is passive exposure: the value showing up automatically in inspect output, in docker compose config, in a YAML file someone commits by accident, or in a crash dump that happens to print the full environment. That's a meaningfully smaller attack surface, even without encryption in the mix.
The uid, gid, and mode Trap
There's a long-form syntax for secrets that looks like it gives you file-permission control:
services:
db:
secrets:
- source: db_password
target: db_password
uid: "999"
gid: "999"
mode: 0400
Set mode: 0400 on a file:-sourced secret and, according to the reference docs, nothing happens. This is genuinely the single most under-reported detail in this whole topic. Compose issue #9648 is the recurring complaint that resulted from it: people set uid, gid, and mode on a file-sourced secret expecting stricter permissions, and the file on disk keeps whatever permissions it already had, with no error and no warning. The reason is structural: those attributes only take effect when the secret's source is environment, not file. When the source is a file, Compose implements it as a bind mount under the hood, and bind mounts don't support uid remapping the way a real secrets store would.
If you actually need those permission attributes honored, the fix is to source the secret from an environment variable instead of a file, using the top-level environment: source that's been available since Compose v2.6.0:
secrets:
db_password:
environment: DB_PASSWORD_VALUE
The observable difference is real: with an environment:-sourced secret, there's no host file for Compose to bind-mount, and the uid/gid/mode attributes are actually applied. Exactly how Compose gets the value into the container without a host file isn't spelled out in the docs, so it's worth describing only what you can verify rather than guessing at internals. What matters for your compose.yaml is simpler: if you write mode: under a file:-sourced secret expecting stricter permissions, you're not getting them, and that's the layer this setting actually lives at, not a bug in your YAML.
Dockerfile Secrets: Keeping Tokens Out of Image Layers
Everything above is a runtime problem: a container that's already built, needing a password at startup. Build time is a separate problem with its own failure mode, and it's actually the bigger search term of the two, which tells you how many people hit it. Say your build needs a private npm token or a package registry credential to pull a dependency. The instinct is to pass it as a build argument:
ARG NPM_TOKEN
RUN npm install
Don't do this for anything sensitive. Docker's build secrets guide states it directly: build arguments and environment variables persist in the final image, because they're baked into a layer. Worse, ARG values are visible in docker history long after the build finishes, so anyone who later gets a copy of your image can extract the token, even if you never publish the Dockerfile itself.
The correct mechanism is RUN --mount=type=secret, which is a BuildKit feature. It exposes the secret's contents only to the single RUN instruction that mounts it, by default at /run/secrets/<id>, and the value never becomes part of any image layer.
# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
npm install
COPY . .
CMD ["node", "server.js"]
The # syntax=docker/dockerfile:1 line at the top matters; it's what tells Docker to parse this with BuildKit, which --mount depends on. Compose v2 uses BuildKit by default, so you don't need to opt in separately there. On the Compose side, you wire the secret through build.secrets on the service, pulling from the same top-level secrets: block used for runtime secrets:
services:
app:
build:
context: .
secrets:
- npm_token
secrets:
npm_token:
file: ./secrets/npm_token.txt
One detail carries over from the runtime mechanism: defining a secret at the top level doesn't automatically grant a service's build access to it. That grant is explicit, service by service, same principle as runtime secrets, just applied to the build stage instead of the running container. If you want a broader tour of multi-stage Dockerfile structure, non-root users, and layer caching around this, the Dockerfile generator builds a working starting point per runtime.
Check Your Compose File Before You Ship It
All of the above assumes you're writing the YAML by hand and keeping every detail straight: which secrets are declared, which services actually get access, whether a password slipped into environment: instead of secrets: during a rushed edit. That's an easy thing to get wrong under deadline pressure, and it's exactly the kind of slip that doesn't throw an error, it just quietly leaves a password sitting in plain YAML. Paste your file into the Compose validator and it flags plain-text passwords and other common YAML mistakes before you commit or deploy. If you're starting a new stack from scratch, the Compose generator can move passwords, tokens, and URLs with embedded credentials into .env as a first step, which is the natural place to begin before you decide whether a given value needs the secrets mechanism on top.
Frequently Asked Questions
How do Docker Compose secrets work without Swarm?
On a single host, Compose implements a secret as a read-only bind mount. The file you point secrets: at gets mounted into the container at /run/secrets/<secret_name>, and that's the whole mechanism, no encryption or external store involved. This has worked since Compose 1.25 even though older documentation implied it required Swarm; that requirement was never actually true for file-based secrets on a single host. Swarm mode, by contrast, stores secrets encrypted in its Raft log and delivers them over an in-memory filesystem, which is a genuinely different and stronger guarantee that plain Compose doesn't provide.
What is the difference between secrets and environment variables?
An environment variable's value sits in the container's process environment and shows up in docker inspect, in docker compose config, and often in crash logs that dump the environment automatically. A file-sourced secret's value lives in a file at /run/secrets/<name>; inspect and config output show that the mount exists, but not what's inside the file. Neither approach encrypts the value. The practical difference is exposure surface: secrets avoid passive leaks through tooling, environment variables don't, and using a secret requires the image's entrypoint to actually know how to read it.
How does the Postgres image read a password file?
Set POSTGRES_PASSWORD_FILE to the secret's mounted path, typically /run/secrets/db_password, instead of setting POSTGRES_PASSWORD directly. The official image's entrypoint script checks for the _FILE-suffixed variable and reads the password from that file during startup. This only applies on first initialization, when the data directory is empty; if you rotate the secret file on a database that's already initialized, the running instance's password doesn't change automatically, and you'd need to update it through SQL as well.
Are secrets visible in docker inspect?
The secret's value is not visible in docker inspect output. Running docker inspect --format '{{.Config.Env}}' against a container using a file-sourced secret shows the _FILE variable pointing at a path, not the password itself, and the Mounts section shows the bind mount's destination and host source path, not the file's contents. That said, anyone with shell access to the running container can still read the file directly with a command like cat, and anyone with read access to the host's secret file can read it there too. Secrets prevent passive exposure through inspection tools; they don't restrict access from inside the container or on the host filesystem.