Docker & DevOps

Manage secrets and environment variables in Docker Compose

Keep API keys and database passwords out of your compose file using.env files, environment keys and Docker secrets, then confirm containers read them correctly.

IntermediateLinux server7 min read

Hard-coding a database password or API key into docker-compose.yml means it lands in Git, in shell history and in every copy of the file. This guide shows how to manage secrets and environment variables in Docker Compose properly: defaults in a .env file, non-sensitive settings as environment keys, and passwords delivered through Docker secrets as files under /run/secrets.

Before you start

You need a Linux VPS running Ubuntu 22.04/24.04, Debian 12 or AlmaLinux/Rocky 8–9, with Docker Engine and the Compose plugin installed. Check both:

Bash
docker --version
docker compose version

If the second command fails, follow install Docker and Docker Compose on a Linux VPS first.

You should also have a project directory with a compose file in it. The examples below use /opt/app and a small web service plus a PostgreSQL database, but the pattern applies to any stack. Log in as a user in the docker group, or use sudo for every docker command.

Warning: docker compose up -d recreates containers when their configuration changes. Any data stored only inside a container is lost at that point. Make sure your database and uploads live in named volumes or bind mounts before you continue — see Docker volumes vs bind mounts.

Understand the three layers

Docker Compose gives you three places to put configuration, and they solve different problems:

  • .env — a file next to your compose file that Compose reads for variable substitution (${VAR}). It feeds the compose file itself, not the container directly.
  • environment / env_file — values injected into the container as environment variables. Fine for log levels, ports and feature flags, visible to anyone who can run docker inspect.
  • secrets — values mounted as read-only files inside the container, normally at /run/secrets/<name>. Better for passwords, tokens and private keys.

Put defaults in a.env file

Create /opt/app/.env with the values your compose file references:

INI
# /opt/app/.env
APP_PORT=8080
APP_ENV=production
POSTGRES_DB=appdb
POSTGRES_USER=deploy
POSTGRES_PASSWORD=change-me-to-a-long-random-string

Lock the file down so other users on the VPS cannot read it:

Bash
cd /opt/app
chmod 600 .env
ls -l .env

You should see -rw------- 1 deploy deploy. Add .env to .gitignore before your first commit, and commit a .env.example with placeholder values instead.

Compose loads .env automatically from the directory you run the command in. Variables already exported in your shell take precedence over the file, which is handy for one-off overrides but can also surprise you — check with docker compose config when a value looks wrong.

Tip: Values containing $, # or spaces need quoting. Use single quotes in .env to keep the value literal: DB_PASSWORD='p@ss$word#1'.

Reference variables in the compose file

Rewrite the compose file to use substitution instead of literals:

YAML
# /opt/app/docker-compose.yml
services:
  app:
    image: example/app:1.4.2
    restart: unless-stopped
    ports:
      - "${APP_PORT}:8080"
    environment:
      APP_ENV: "${APP_ENV}"
      DB_HOST: db
      DB_NAME: "${POSTGRES_DB}"
      DB_USER: "${POSTGRES_USER}"
    secrets:
      - db_password
    depends_on:
      - db

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: "${POSTGRES_DB}"
      POSTGRES_USER: "${POSTGRES_USER}"
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    volumes:
      - db_data:/var/lib/postgresql/data
    secrets:
      - db_password

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

volumes:
  db_data:

A few points about this file:

  • ${VAR} is replaced by Compose at parse time, from .env or your shell.
  • POSTGRES_PASSWORD_FILE tells the official Postgres image to read the password from a file instead of an environment variable. Many official images support a _FILE variant; check the image docs, since support varies.
  • The secrets block at the top level defines where the value comes from. file: points to a path relative to the compose file.

Create the secret file and restrict it:

Bash
mkdir -p /opt/app/secrets
printf '%s' 'change-me-to-a-long-random-string' > /opt/app/secrets/db_password.txt
chmod 700 /opt/app/secrets
chmod 600 /opt/app/secrets/db_password.txt

Use printf rather than echo so no trailing newline ends up inside the password. Generate a real value with openssl rand -base64 32.

Use Docker Compose secrets safely

Start the stack:

Bash
cd /opt/app
docker compose up -d
docker compose ps

Compose mounts defined secrets into the container at /run/secrets/<name>, read-only and owned by root. Your application reads the file at startup instead of an environment variable. In a shell script that looks like this:

Bash
DB_PASSWORD="$(cat /run/secrets/db_password)"
export PGPASSWORD="$DB_PASSWORD"

In Python, open("/run/secrets/db_password").read.strip. In Node.js, fs.readFileSync("/run/secrets/db_password", "utf8").trim. Trim whitespace — a stray newline is the most common cause of "password authentication failed" after moving from environment variables to secrets.

Warning: Environment variables are readable by any process in the container and appear in docker inspect, in crash dumps and often in logs. Do not put long-lived credentials in environment: blocks. Use secrets for anything that would hurt if it leaked.

For multi-line values such as a private key, write the file with a trailing newline and let the client library handle it, or store the whole certificate chain in one secret file and mount it at the expected path.

Keep secrets out of the image and out of Git

Three habits prevent most leaks:

  1. Never COPY.. into an image without a .dockerignore that lists .env and secrets/.
  2. Never commit .env or secrets/ — confirm with git status before pushing.
  3. Keep the secret files readable only by the deploy user, as shown above.

Check an image before you push it:

Bash
docker build -t example/app:local .
docker run --rm --entrypoint sh example/app:local -c 'ls -a /app | grep -E "^\\.env$|^secrets$" || echo "clean"'

If that prints clean, neither path made it into the image layer.

Check it works

Confirm the resolved configuration and the mounts:

Bash
cd /opt/app
docker compose config --quiet && echo "compose file valid"
docker compose exec app ls -l /run/secrets
docker compose exec app sh -c 'wc -c < /run/secrets/db_password'

The first command should print compose file valid and nothing else. The second should list db_password as -r--r--r--, and the third should print the byte count of your password — not 0 and not an error.

Then verify the application can actually reach the database:

Bash
docker compose logs --tail=50 app
docker compose exec db psql -U deploy -d appdb -c 'select 1;'

A 1 row means the database accepted the credentials it read from the secret file. Finally, confirm no password ended up in the container environment:

Bash
docker compose exec app env | grep -i -E 'password|secret|token' || echo "no credentials in environment"

Troubleshooting

  • The "POSTGRES_PASSWORD" variable is not set. Defaulting to a blank string. — the variable is missing from .env or from your shell. Check spelling and that you run docker compose from the directory containing .env.
  • cat: /run/secrets/db_password: No such file or directory — the service does not list the secret under its own secrets: key, or file: points to a path relative to the compose file that does not exist. Run docker compose config and look at the rendered secrets block.
  • password authentication failed right after switching to secrets — a trailing newline in the secret file, or the app still reads the old environment variable. Regenerate with printf, restart with docker compose up -d --force-recreate, and check the code path.
  • Variable expands to empty in the container but is set on the host — exported shell variables override .env, and env_file entries are overridden by environment in the same service. Keep one source of truth per key.
  • Secret visible in docker compose config output — expected: substitution happens before the container starts, so Compose prints resolved values. Do not paste that output into tickets or chat; redact before sharing.

Next steps

Was this guide helpful?

Still stuck?

Ask MdmHost AI for a quick answer, or open a ticket and our team will take a look. We are here 24/7.