Environment variables are the default way to pass credentials to Docker containers — but they have real security problems. Anyone who can run docker inspect on your container can read every environment variable, including passwords and API keys.

Docker Compose secrets solve this by mounting sensitive values as files, accessible only to the processes that explicitly need them. This guide explains how they work, when to use them, and practical setup for self-hosted stacks like n8n, WordPress, and Postgres.

The Problem with Environment Variables for Secrets

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: my_super_secret_password   # visible to anyone

When you use environment variables for secrets, they’re exposed in:

  • docker inspect <container> output (anyone with Docker socket access can read this)
  • Process lists (/proc/<pid>/environ on Linux)
  • Docker daemon logs in some configurations
  • Application crash dumps and error logs if the app prints its environment

For a single-developer setup on a private VPS, this risk is manageable. For multi-user environments or compliance requirements, it’s a real problem.

How Docker Compose Secrets Work

Secrets are mounted as files inside containers at /run/secrets/<secret-name>. The container reads the file contents at runtime — the value never appears in environment variable listings.

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: myuser
      POSTGRES_DB: mydb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password  # points to the file
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt  # source: a file on the host

The db_password.txt file contains only the password, nothing else:

my_super_secret_password

Inside the container, the password is accessible at /run/secrets/db_password. The official postgres image (and many others) supports _FILE environment variable variants that read from this path automatically.

Setting Up Secrets: Step by Step

Step 1: Create the secrets directory

mkdir -p ./secrets
echo -n "my_secure_db_password" > ./secrets/db_password.txt
echo -n "my_secure_redis_password" > ./secrets/redis_password.txt
chmod 600 ./secrets/*.txt

The -n flag prevents echo from adding a trailing newline (important — some applications fail to parse credentials with trailing newlines).

Step 2: Add secrets to .gitignore

# .gitignore
secrets/
*.secret
.env.local

Never commit secret files to version control.

Step 3: Configure docker-compose.yml

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_USER: myuser
      POSTGRES_DB: mydb
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U myuser -d mydb"]
      interval: 10s
      retries: 5

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

volumes:
  postgres_data:

Step 4: Verify the secret is mounted correctly

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

The password should print (without a trailing newline). If Postgres started successfully, it read the password correctly.

Which Images Support _FILE Variables?

Many official Docker Hub images support the _FILE convention:

ImageVariableFile variant
postgresPOSTGRES_PASSWORDPOSTGRES_PASSWORD_FILE
mysqlMYSQL_PASSWORDMYSQL_PASSWORD_FILE
mariadbMARIADB_PASSWORDMARIADB_PASSWORD_FILE
redisREDIS_PASSWORDREDIS_PASSWORD_FILE
mongoMONGO_INITDB_ROOT_PASSWORDMONGO_INITDB_ROOT_PASSWORD_FILE
wordpressNo native support (use workaround below)

Check the image’s documentation on Docker Hub before assuming _FILE support exists.

Full Stack Example: n8n + Postgres + Caddy

A production-ready self-hosted automation stack using secrets:

./secrets/postgres_password.txt: change_me_in_production ./secrets/n8n_encryption_key.txt: a_32_char_random_string_here____

docker-compose.yml:

services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_USER: n8n
      POSTGRES_DB: n8n
      POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
    secrets:
      - postgres_password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U n8n"]
      interval: 10s
      retries: 5

  n8n:
    image: docker.n8n.io/n8nio/n8n:latest
    environment:
      DB_TYPE: postgresdb
      DB_POSTGRESDB_HOST: postgres
      DB_POSTGRESDB_DATABASE: n8n
      DB_POSTGRESDB_USER: n8n
      DB_POSTGRESDB_PASSWORD_FILE: /run/secrets/postgres_password
      N8N_ENCRYPTION_KEY_FILE: /run/secrets/n8n_encryption_key
      N8N_HOST: automation.yourdomain.com
      N8N_PROTOCOL: https
      WEBHOOK_URL: https://automation.yourdomain.com/
    secrets:
      - postgres_password
      - n8n_encryption_key
    ports:
      - "5678:5678"
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - n8n_data:/home/node/.n8n

secrets:
  postgres_password:
    file: ./secrets/postgres_password.txt
  n8n_encryption_key:
    file: ./secrets/n8n_encryption_key.txt

volumes:
  postgres_data:
  n8n_data:

This setup means the Postgres password never appears in docker inspect output on either container.

Reading Secrets in Custom Applications

If you’re using an image that doesn’t support _FILE variables, read the secret file in your application code or entrypoint:

entrypoint.sh:

#!/bin/sh

# Read secrets from files if _FILE env vars are set
if [ -n "$DB_PASSWORD_FILE" ] && [ -f "$DB_PASSWORD_FILE" ]; then
  DB_PASSWORD=$(cat "$DB_PASSWORD_FILE")
  export DB_PASSWORD
fi

exec "$@"

Dockerfile:

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["node", "server.js"]

docker-compose.yml:

services:
  app:
    build: .
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

Secrets with External Source (Environment Variable)

Instead of a file, define a secret from a shell environment variable:

secrets:
  api_key:
    environment: EXTERNAL_API_KEY   # reads from host shell environment

This works in CI/CD pipelines where secrets come from the pipeline’s secret store (GitHub Actions, GitLab CI, etc.) rather than files on disk.

Secrets vs .env File: When to Use Which

ScenarioUse secretsUse .env
Database password✅ Preferred⚠️ Acceptable for dev
API keys with billing impact✅ Preferred⚠️ Acceptable for dev
Feature flags❌ Overkill
Hostnames, ports, app config❌ Overkill
SSL private keys✅ Required
Compliance requirement (SOC2, GDPR)✅ Required
Single-developer dev VPS❌ Optional✅ Sufficient

Rotating Secrets Without Downtime

When a credential needs to be rotated:

# 1. Write the new password to the secret file
echo -n "new_secure_password" > ./secrets/db_password.txt

# 2. Update the password in the database first
docker compose exec db psql -U postgres -c "ALTER USER myuser WITH PASSWORD 'new_secure_password';"

# 3. Restart the application containers (not the database)
docker compose restart app

# 4. Verify the app reconnects successfully
docker compose logs -f app

Order matters: update the database password first, then rotate the secret file and restart apps. Doing it in reverse order causes a connection failure window.

Auditing What Secrets Are Accessible

To verify which secrets a container can access:

docker compose exec web ls /run/secrets/
docker compose exec web cat /run/secrets/api_key

To confirm secrets are NOT visible in environment:

docker compose exec web env | grep -i password   # should return nothing
docker inspect $(docker compose ps -q web) | grep -i password   # should return nothing

Common Mistakes

Trailing newline in secret file

# WRONG — echo adds a newline by default
echo "mypassword" > secrets/db_password.txt

# CORRECT — -n prevents the newline
echo -n "mypassword" > secrets/db_password.txt

Many applications fail silently when credentials have trailing newlines. Always use echo -n or printf '%s'.

Secret file with wrong permissions

# Make secret files readable only by root
chmod 600 secrets/*.txt
chown root:root secrets/*.txt

On a shared server, world-readable secret files defeat the purpose.

Committing secrets accidentally

Set up git hooks to prevent this:

# .git/hooks/pre-commit
#!/bin/bash
if git diff --cached --name-only | grep -q "secrets/"; then
  echo "ERROR: You're about to commit files from the secrets/ directory."
  exit 1
fi

Self-Hosting on a VPS

Running a secrets-aware Docker Compose stack requires a Linux VPS with Docker installed. Hetzner Cloud offers an excellent entry point — a 2 vCPU / 4 GB server costs €4.15/month and handles n8n, Postgres, and WordPress simultaneously. New accounts get €20 free credits.

For US and global locations, Vultr has data centers on 6 continents with plans starting at $6/month and $100 free credits. DigitalOcean offers a comparable setup with $200 free credits and excellent documentation for Docker deployments.

Quick Reference

TaskCommand
Create secret fileecho -n "value" > secrets/name.txt
Set permissionschmod 600 secrets/*.txt
Verify secret mounteddocker compose exec svc cat /run/secrets/name
Confirm not in envdocker compose exec svc env | grep name
Inspect container secretsdocker inspect <id> | grep -A5 Mounts

Summary

Docker Compose secrets eliminate the biggest security risk in containerized stacks — credentials sitting in environment variables visible to docker inspect. The implementation is simple: create a file containing the secret, reference it in the compose file under secrets:, and use _FILE environment variables to point your service at the file path.

For single-developer VPS setups, a well-managed .env.local in .gitignore is often sufficient. For production stacks handling real user data, multi-user access, or compliance requirements, secrets-based configuration is the right approach and adds almost no operational overhead.