Skip to main content
Self-Hosting & Privacy

Docker Compose Secrets vs .env Files

I pasted a fresh Postgres password straight into environment: on a new compose file, then stopped with my cursor over git add . That password was about to sit in plain text in a repo I push to self-hosted Gitea, not because I was careless, but because environment: is the path of least resistance.

milanbuha00August 21, 20266 min read
ShareXin
Docker Compose Secrets vs .env Files

I pasted a fresh Postgres password straight into environment: on a new compose file, then stopped with my cursor over git add .. That password was about to sit in plain text in a repo I push to a self-hosted Gitea instance — not because I'm careless, but because environment: is the path of least resistance and Compose never warns you.

TL;DR

  • environment: writes the value into the container's environment and into docker inspect output, in plain text either way.
  • env_file: keeps the value out of the compose file itself, but it still ends up in docker inspect once the container is running.
  • The top-level secrets: block is not Swarm-only in Compose v2 — it works standalone, and mounts the value as a file the container reads, never as an environment variable.
  • secrets: isn't hot-reloadable and needs a file-backed source, so it's real ceremony for a single-host homelab, not a default.
  • This site's actual pattern: .env.example checked into git, .env gitignored, env_file: in every compose service.

KEY-STAT: 65% — Share of container secret leaks GitGuardian's 2025 State of Secrets Sprawl report attributed to ENV-based exposure alone

The three ways Compose hands a container a secret

environment: sets the variable directly in the compose file, either as a literal value or ${VAR} interpolated from the shell or a .env file Compose auto-loads. Whatever the source, the final value lands in the compose file's rendered config and in the running container's environment — visible to anything that can run docker inspect on that container, and to any process inside it that reads its own environment.

env_file: points at a separate file (commonly .env) instead of writing values in the compose file. The compose file itself stays clean of literal secrets, which is the entire reason to use it. But once the container starts, those values are injected as environment variables the same way environment: does it — so docker inspect still shows them, and any code inside the container that dumps its environment (a stack trace, a debug endpoint, a compromised dependency) still exposes them.

secrets: is different in kind, not just packaging. Declare a secret under the compose file's top-level secrets: key, backed by a file or an external secret store, then reference it under a service's own secrets: list. Docker mounts it as a read-only file at /run/secrets/<name> inside the container — it is never set as an environment variable, so it doesn't show up in docker inspect, process listings, or a stray environment dump.

Where each one actually leaks

environment:env_file:secrets:
Visible in docker inspectYesYesNo
Visible in image layers (if baked at build time)Yes, if used in a Dockerfile ENVYes, if copied in at build timeNo — mounted at runtime only
Risk of accidental git addHigh — value lives in the compose fileLow — value lives in a separate file you gitignoreLow — same as env_file, the backing file is gitignored
Visible in container's own environment dumpYesYesNo — it's a file, not a variable
Hot-reloadable without container restartNoNoNo

When secrets: earns its keep outside Swarm

The most common myth about secrets: is that it requires Swarm mode. It doesn't — Compose v2 supports the top-level secrets: key on a plain docker compose up, backed by a local file instead of the Swarm-managed secret store. That means a single Proxmox VM running Compose can use real file-mounted secrets, not just Swarm clusters.

The catch is real ceremony for real security: a secrets: value has to already exist as a file on disk (or be piped in from a command), it isn't hot-reloadable — change the file and you still restart the container to pick it up — and application code has to be written (or configured) to read a file path instead of an environment variable, which not every off-the-shelf container image supports out of the box. For a homelab running a handful of self-hosted apps where the main threat model is "don't leak this into git or a screenshot," that ceremony often isn't worth it. For anything holding a credential with real blast radius — a database superuser password, an API key with billing access — it is.

Warning

Neither environment: nor env_file: protects a secret from anyone with docker inspect access to the host, including anyone who can read docker-compose.yml after a value was ever interpolated into a log or CI output. Treat both as "keeps it out of git," not as "keeps it secret from the host."

The .env + .gitignore pattern this site actually uses

Every compose stack on this homelab follows the same convention: a .env.example file is checked into the repo with every key name and a placeholder value, .env itself is listed in .gitignore and never committed, and each service's compose block uses env_file: .env instead of inline environment: values. Cloning the repo onto a new host means copying .env.example to .env and filling in real values once — the compose file itself never needs editing, and there's nothing to accidentally commit because the real values never touch a tracked file.

Tip

Add a pre-commit check (even a one-line grep in a git hook for .env in git diff --cached --name-only) if you've ever accidentally staged a .env file before — it happens once, and then never again once the hook exists.

That pattern is env_file:, not secrets: — it solves the git-leak problem, which is the actual daily risk for a homelab, without the file-mounting ceremony secrets: requires. The Docker Compose networking guide and volumes and backups guide cover the other two per-service decisions made the same deliberate way — storage and networking are chosen per service, not defaulted project-wide, and secrets handling follows the same logic.

Decision table: which one for this credential?

ScenarioRecommendation
API key or DB password on a personal homelab, main risk is git exposureenv_file: with .env gitignored
Credential with real blast radius (DB superuser, billing-scoped API key) on a host others can docker inspectsecrets:, file-backed, even outside Swarm
Value that changes often and needs a live container restart anywayenv_file:secrets:'s no-hot-reload limitation costs nothing extra here
Self-hosted Git server holding the repo (see the Gitea vs Forgejo vs GitLab CE comparison)Either — the point is the value never reaches that repo in the first place
Literal value inline in environment: for anything sensitiveDon't — it's the one option guaranteed to end up in the compose file itself

The bottom line

environment: and env_file: both end up in docker inspect; the difference between them is only whether the value ever touches a file you might commit. secrets: is the one option that keeps a credential out of the container's environment entirely, and it works on a single Compose host without Swarm — but its file-mounting and no-hot-reload requirements make it worth reaching for only when a credential's blast radius justifies the ceremony. For most homelab API keys and DB passwords, a disciplined .env + .gitignore convention closes the actual risk that matters day to day.

Frequently asked questions

Is it safe to use .env files in production Docker?

It keeps values out of the compose file and git history, which closes the most common leak path. It does not hide the values from anything with docker inspect access to the host — for credentials with high blast radius, use the top-level secrets: block instead.

Does Docker Compose secrets require Swarm mode?

No. Compose v2 supports the top-level secrets: key backed by a local file on a plain docker compose up, without joining or initializing a Swarm.

What's the difference between env_file and environment in Docker Compose?

environment: writes values directly in the compose file; env_file: points at a separate file instead. Both end up as environment variables inside the running container and both show up in docker inspect — the difference is only whether the value can accidentally end up in a tracked compose file.

How do I keep API keys out of Docker Compose files?

Use env_file: pointing at a .env file that's listed in .gitignore, and check in a .env.example with placeholder values so the real keys never touch a tracked file. For credentials with serious blast radius, use the secrets: block instead, which never exposes the value as an environment variable at all.

Related stories

More from Self-Hosting & Privacy

Stay in the loop

Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.

Read next

Docker Compose Build vs Pull vs --no-cache

I edited a Dockerfile, ran docker compose up -d, and the container came back up running the exact same code it had before. No error, no warning, just silence, and a service that looked started but was not the one I had just changed.

Continue Reading