Passing a database password to a container with environment: works, but anyone who runs docker inspect will see it in plain text. That is the trap that separates ordinary configuration from sensitive data. In this guide you will see the three ways to inject configuration with Docker Compose (the environment key, the env_file attribute and the .env file), how their precedence is ordered, and why passwords and API keys should go through the secrets mechanism instead of environment variables. The same guide is available in Spanish.

Key takeaways

  • Docker Compose resolves variables with a five-level precedence: docker compose run -e wins, then the interpolated shell value, then environment, then env_file, and last the image’s ENV.
  • The .env file is for interpolating values inside docker-compose.yml (${VAR}), not for injecting them automatically into the container: that is still the job of environment or env_file.
  • When the same key appears in both environment and env_file, environment wins.
  • Docker Compose secrets are defined as files and mounted read-only at /run/secrets/<name>, outside the process environment and outside docker inspect output.
  • Official images (PostgreSQL, MariaDB) support the _FILE convention: POSTGRES_PASSWORD_FILE=/run/secrets/db_password reads the password from the file rather than a variable.
  • Since Compose v2.24.0 you can mark an env_file as optional with required: false.

How do you pass configuration into a container?

A container starts knowing nothing about your server: it does not know the port you want to expose, the database name, or the time zone. Docker Compose gives you three ways to tell it, and you should avoid mixing them by accident.

The first is the environment key inside the service, where you write key-value pairs directly in docker-compose.yml. The second is env_file, which points to one or more files holding those same pairs, one per line, so the Compose file stays clean. The third is the .env file in the project directory, and this is where the most common confusion sits: that .env is not injected into the container. Compose uses it to interpolate values inside docker-compose.yml itself. If you define APP_PORT=8080 in .env, you can write "${APP_PORT}:8080" in the ports section, but the container only receives APP_PORT if you also declare it under environment.

This distinction causes most beginner problems. Before going further, if you do not have the engine installed yet, review how to install Docker on Ubuntu 24.04.

What does the .env file and variable interpolation do?

Compose automatically loads a file called .env from the directory where you run the command. Its values become available for interpolation: any ${VARIABLE} or $VARIABLE that appears in docker-compose.yml is substituted with the matching value before the containers are created. It supports both the braced form ${VAR} and the short form $VAR, plus default values with ${VAR:-value}.

A typical .env for the example below looks like this:

APP_PORT=8080
LOG_LEVEL=info
POSTGRES_VERSION=16.4

With that you can reference ${APP_PORT} or ${POSTGRES_VERSION} in the Compose file and change the port or the image version without touching the YAML. The key point: use .env to parameterise docker-compose.yml, not as a password store, because it is a plain-text file that easily ends up in the repository. Always add it to your .gitignore.

env_file versus environment: which one wins?

Both keys inject variables into the container, but they behave differently. environment is written in the Compose file and suits non-sensitive, rarely changing values. env_file externalises those pairs into a separate file, handy when you have many variables or want one file per environment (development, testing, production).

When the same key sits in both places, environment wins. And the global precedence, highest to lowest, follows these five rules per Docker’s documentation:

  1. What you pass with docker compose run -e on the command line.
  2. An environment or env_file value interpolated from the shell or an environment file.
  3. The value set only in environment.
  4. The value set in env_file.
  5. The ENV defined in the image (the Dockerfile).

Since Compose v2.24.0 you can declare an optional env_file with required: false: if the file is missing, Compose silently ignores the entry instead of failing. And since v2.30.0 the format attribute allows alternative file formats. These improvements sit alongside the Compose v2 hallmark: you no longer write the version: key at the top of the file.

Docker Compose secrets: why not use variables for passwords

Here is the shift in mindset. An environment variable is visible to any process in the container, shows up in docker inspect, is inherited by child processes and often ends up printed in debug logs. Docker’s own documentation puts it plainly: "If you’re injecting passwords and API keys as environment variables, you risk unintentional information exposure." Its recommendation is clear: use the secrets mechanism.

A secret in Compose is, in its simplest form, a file whose contents are mounted read-only at /run/secrets/<name> inside the container. It is not in the process environment, docker inspect does not list it, and you control access with ordinary file permissions. Official images close the loop with the _FILE convention: instead of POSTGRES_PASSWORD you pass POSTGRES_PASSWORD_FILE=/run/secrets/db_password, and the image reads the password from the file.

Here is a complete, copy-paste docker-compose.yml with a database and an application that share a password as a secret:

services:
  db:
    image: postgres:16.4
    restart: unless-stopped
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: myapp
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myapp"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: nginx:1.27.2
    restart: unless-stopped
    env_file:
      - path: .env
        required: true
    environment:
      DB_HOST: db
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "${APP_PORT}:80"

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

volumes:
  db_data:

Before bringing it up, create the secret file and the .env, then start the stack:

mkdir -p secrets
openssl rand -base64 24 > secrets/db_password.txt
chmod 600 secrets/db_password.txt

printf 'APP_PORT=8080\n' > .env

docker compose up -d
docker compose exec db cat /run/secrets/db_password

The last command confirms the secret is mounted inside the container. If you compare the output of docker inspect db with a service that used POSTGRES_PASSWORD under environment, you will see the difference: with the secret, the password appears nowhere. To persist that data properly, review how volumes and bind mounts in Docker work, and to make the app wait for a healthy database, lean on healthchecks and restart policies.

One important nuance: Compose secrets outside Swarm are bind mounts of local files. Docker Swarm adds encrypted external secrets in its Raft log, which you create with docker secret create and reference with external: true. For a single-host homelab, the file version is enough and far simpler.

Best practices and common mistakes

After standing up a few services, these are the most repeated slip-ups and how to avoid them:

  • Do not pin :latest in production. The :latest tag is not reproducible: a silent update can break your startup. Pin a specific version such as postgres:16.4 and use :latest only as a warning of what not to do.
  • Ignore sensitive files. Add .env, secrets/ and any password *.txt to your .gitignore before the first commit. A secret in Git history is already compromised.
  • Do not confuse .env with container variables. The .env interpolates in the YAML; for a variable to reach the container, declare it in environment or env_file.
  • Give the secret file strict permissions. A chmod 600 stops other host users from reading it.
  • Rotate your secrets. If you are going to manage many credentials, a manager like Vaultwarden helps you generate and store them outside the repository.

Storing configuration in the environment, not in the code, is a classic principle (formalised by the Twelve-Factor App methodology). Secrets are the practical exception to it: they go in mounted files, not variables.

Frequently asked questions

Is the .env file injected automatically into the container?

No. Compose loads .env to interpolate ${VARIABLE} inside docker-compose.yml, but the container does not receive those variables unless you declare them in environment or env_file. It is the most common confusion: .env parameterises the Compose file, it does not fill the container environment on its own.

Can I see a Docker secret with docker inspect?

No, and that is exactly the point. Unlike an environment variable, which docker inspect shows in plain text, a secret is mounted as a read-only file at /run/secrets/<name> and is not part of the configuration the Docker API exposes.

Do I need Docker Swarm to use secrets?

Not for the file-based version. Docker Compose mounts secrets from local files without Swarm. Swarm is only required for external secrets encrypted in the Raft log, meant for multi-node clusters.

Conclusion

The rule is simple: ordinary configuration through environment or env_file, Compose parameterisation with .env, and passwords and keys always as secrets mounted at /run/secrets/. With that separation your docker-compose.yml stays clean, reproducible and free of credentials in the open. The natural next step is chaining services with healthy dependencies: review healthchecks and restart policies in Docker Compose so your app does not start before its database.

Sources

  1. Docker Docs: Environment variables in Compose
  2. Docker Docs: Manage secrets securely in Docker Compose
  3. docker/compose, Releases
  4. The Twelve-Factor App: Store config in the environment

Route: Self-hosting with Docker: from zero to production