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:
docker --version
docker compose versionIf 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 rundocker 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:
# /opt/app/.env
APP_PORT=8080
APP_ENV=production
POSTGRES_DB=appdb
POSTGRES_USER=deploy
POSTGRES_PASSWORD=change-me-to-a-long-random-stringLock the file down so other users on the VPS cannot read it:
cd /opt/app
chmod 600 .env
ls -l .envYou 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:
# /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.envor your shell.POSTGRES_PASSWORD_FILEtells the official Postgres image to read the password from a file instead of an environment variable. Many official images support a_FILEvariant; check the image docs, since support varies.- The
secretsblock 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:
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.txtUse 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:
cd /opt/app
docker compose up -d
docker compose psCompose 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:
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:
- Never
COPY..into an image without a.dockerignorethat lists.envandsecrets/. - Never commit
.envorsecrets/— confirm withgit statusbefore pushing. - Keep the secret files readable only by the deploy user, as shown above.
Check an image before you push it:
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:
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:
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:
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.envor from your shell. Check spelling and that you rundocker composefrom the directory containing.env.cat: /run/secrets/db_password: No such file or directory— the service does not list the secret under its ownsecrets:key, orfile:points to a path relative to the compose file that does not exist. Rundocker compose configand look at the renderedsecretsblock.password authentication failedright after switching to secrets — a trailing newline in the secret file, or the app still reads the old environment variable. Regenerate withprintf, restart withdocker 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, andenv_fileentries are overridden byenvironmentin the same service. Keep one source of truth per key. - Secret visible in
docker compose configoutput — 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
- Read Docker Compose file explained for services, volumes, networks and ports in detail.
- Harden the host and the daemon with the Docker security checklist for a public VPS.
- Run containers without root where possible using Docker persistent storage for non-root users.
