Docker Compose Secrets: Secure Config Management Guide

Docker Compose Secrets: How to Stop Leaking Credentials in Your Containers

Disclosure: This post contains one or more links to providers we have a real, registered affiliate/referral relationship with. We may earn a commission at no extra cost to you if you sign up through them.

If you’ve ever pasted a database password directly into a docker-compose.yml file under environment:, you’ve already leaked it. It’s sitting in plaintext in your git history, your shell history, and probably a Slack thread from six months ago. Docker Compose secrets exist specifically to fix this problem, and most teams still aren’t using them correctly — or at all.

This guide covers what Docker Compose secrets actually are, how they differ from environment variables, and how to wire them into real services like Postgres, Redis, and custom apps. We’ll also cover the common mistakes that make people think secrets “don’t work” in Compose (they usually do — you’re just using the wrong driver mode).

Why Environment Variables Aren’t Enough

The default instinct for passing a database password to a container is:

services:
  db:
    image: postgres:16
    environment:
      POSTGRES_PASSWORD: hunter2

This works, but it has real problems:

  • The value is visible in docker inspect <container> output to anyone with Docker socket access.
  • It’s committed to version control unless you’re careful with .gitignore, and even then it often ends up in CI logs.
  • It shows up in process listings and container logs if any tooling echoes environment variables during startup.
  • Every service that needs the secret gets it injected as a full environment variable, widening the blast radius if one container is compromised.
  • Docker Compose secrets solve this by mounting sensitive values as files inside the container’s filesystem, typically under /run/secrets/, rather than injecting them into the environment. Only the services that explicitly declare a dependency on a secret can read it, and the value never appears in docker inspect or docker compose config output.

    How Docker Compose Secrets Actually Work

    A secret in Compose is defined at the top level of your docker-compose.yml, then attached to individual services. There are two ways to source a secret:

    1. File-based — the secret’s contents come from a local file on the host.
    2. External — the secret already exists (commonly in Docker Swarm) and Compose just references it by name.

    Here’s the file-based approach, which is what most single-host and local dev setups use:

    services:
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/db_password
        secrets:
          - db_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt

    Notice the POSTGRES_PASSWORD_FILE variable instead of POSTGRES_PASSWORD. The official Postgres image (and many others) supports a _FILE suffix convention: instead of reading the value directly from an environment variable, the entrypoint script reads it from the file path you provide. Inside the container, Compose mounts the secret at /run/secrets/db_password, readable only by root by default with 0444 permissions.

    Create the secret file locally:

    mkdir -p secrets
    echo "hunter2" > secrets/db_password.txt
    chmod 600 secrets/db_password.txt

    Then add secrets/ to your .gitignore immediately — this is the single most common way secrets leak back into git despite people setting up the whole system correctly.

    echo "secrets/" >> .gitignore

    Using Secrets in Your Own Application Code

    Not every image supports the _FILE convention, so for custom apps you’ll read the secret file directly. Here’s a minimal Node.js example:

    const fs = require('fs');
    
    function readSecret(name) {
      const path = `/run/secrets/${name}`;
      return fs.readFileSync(path, 'utf8').trim();
    }
    
    const dbPassword = readSecret('db_password');
    const apiKey = readSecret('third_party_api_key');

    And the equivalent Python pattern:

    from pathlib import Path
    
    def read_secret(name: str) -> str:
        return Path(f"/run/secrets/{name}").read_text().strip()
    
    db_password = read_secret("db_password")

    This pattern works identically whether you’re running plain docker compose up locally or deploying to a Swarm cluster, which makes it worth adopting even if you think you’ll never touch Swarm.

    Multiple Secrets Across Multiple Services

    A realistic stack usually needs several secrets shared across services with different access levels. Here’s a fuller example with Postgres, Redis with a password, and an app container that needs both plus a third-party API key:

    services:
      app:
        build: .
        depends_on:
          - db
          - cache
        secrets:
          - db_password
          - api_key
    
      db:
        image: postgres:16
        environment:
          POSTGRES_PASSWORD_FILE: /run/secrets/db_password
        secrets:
          - db_password
    
      cache:
        image: redis:7
        command: ["redis-server", "--requirepass-file", "/run/secrets/redis_password"]
        secrets:
          - redis_password
    
    secrets:
      db_password:
        file: ./secrets/db_password.txt
      redis_password:
        file: ./secrets/redis_password.txt
      api_key:
        file: ./secrets/api_key.txt

    Note that redis-server doesn’t natively support a --requirepass-file flag out of the box in older versions — check your image’s documentation, and if it’s unsupported, wrap the container’s entrypoint with a small shell script that reads the file and passes it as --requirepass "$(cat /run/secrets/redis_password)" instead.

    Each service only lists the secrets it actually needs under its own secrets: key. The app service can read db_password and api_key, but has no access to redis_password unless you explicitly add it — this is the principle of least privilege applied to container secrets.

    Secrets vs. .env Files: When to Use Which

    A lot of confusion comes from Compose also supporting .env files and env_file: for non-sensitive configuration. These are different tools for different jobs:

  • .env / env_file — good for non-sensitive configuration like LOG_LEVEL=debug or APP_PORT=3000. Values still land in the environment and are visible via docker inspect.
  • secrets: — good for anything sensitive: passwords, API keys, TLS private keys, tokens. Values are mounted as files, not injected into the process environment, and are excluded from docker compose config output when using external secret backends.
  • A reasonable rule: if leaking the value in a support ticket or screen share would be embarrassing or dangerous, it belongs in secrets:, not .env.

    For teams running this on a VPS rather than a laptop, pairing this setup with a hardened host matters just as much as the Compose file itself. If you’re provisioning that host yourself, a provider like DigitalOcean or Hetzner gives you a clean Ubuntu box to configure a proper firewall and non-root Docker setup on — we cover the baseline hardening steps in our Docker security checklist.

    Secrets in Docker Swarm vs. Plain Compose

    It’s worth being explicit about a limitation: in plain docker compose up (no Swarm), secrets are still just bind-mounted files under the hood, not encrypted at rest by Docker itself. The security benefit is about avoiding environment variable exposure and docker inspect leakage, not full encryption.

    In Docker Swarm mode, secrets are genuinely encrypted at rest in the Raft log and only decrypted in memory on the nodes running containers that need them. If you need that stronger guarantee, you’d deploy with docker stack deploy instead of docker compose up, using the same secrets: syntax:

    docker swarm init
    docker stack deploy -c docker-compose.yml mystack

    For most single-host deployments — a small VPS running a handful of services — plain Compose secrets are sufficient as long as you also lock down file permissions on the host and restrict who has SSH and Docker group access. For anything handling real customer data across multiple nodes, Swarm secrets or a dedicated secrets manager like HashiCorp Vault is the more defensible choice.

    Rotating Secrets Without Downtime

    One underrated benefit of file-based secrets is that rotation doesn’t require rebuilding your image. To rotate the db_password:

    echo "new-strong-password" > secrets/db_password.txt
    docker compose up -d --force-recreate db app

    This recreates only the affected containers with the new secret mounted, without touching unrelated services. Compare that to environment-variable-based secrets baked into a .env file referenced by many services — rotating those often means restarting everything and hoping you didn’t miss a reference somewhere.

    If you’re monitoring uptime during a rotation like this, a service like BetterStack will catch it immediately if a recreated container fails health checks, which is worth having in place before you start touching production credentials.

    Common Mistakes to Avoid

  • Committing the secrets file anyway. .gitignore only prevents future commits — if secrets/db_password.txt was ever committed, it’s in your git history forever unless you rewrite it with git filter-repo or BFG Repo-Cleaner.
  • Using POSTGRES_PASSWORD and POSTGRES_PASSWORD_FILE together. Some images will silently prefer one over the other, or error out. Pick one convention per variable.
  • Forgetting file permissions on the host. A secret file with 644 permissions readable by any local user defeats the purpose. Use chmod 600.
  • Assuming plain Compose secrets are encrypted at rest. They aren’t, only Swarm secrets are — treat host-level access control as the real security boundary for single-host setups.
  • Hardcoding fallback defaults in application code. If your app falls back to a hardcoded password when the secret file is missing, you’ve built a silent security hole that only shows up when something goes wrong with the mount.
  • We go into more depth on locking down the host itself, including firewall rules and non-root container users, in our companion piece on hardening a Docker host on a VPS.

    Recommended: Want to explore DigitalOcean yourself? DigitalOcean is a direct vendor link (not an affiliate/tracked link).

    FAQ

    Do Docker Compose secrets work without Docker Swarm?
    Yes. File-based secrets work fine with plain docker compose up. They’re mounted as read-only files under /run/secrets/ inside the container. The main difference with Swarm is that Swarm additionally encrypts secrets at rest in its cluster state; plain Compose does not.

    Why isn’t my POSTGRES_PASSWORD_FILE being picked up?
    Check that you’re not also setting POSTGRES_PASSWORD in the same service — many official images check for the plain variable first and will ignore the _FILE variant, or throw an error if both are set. Also confirm the secret is actually listed under that service’s secrets: key, not just in the top-level secrets: block.

    Can I use environment variables to reference secret values directly?
    Not securely, no. If you do MY_SECRET: ${SOME_SECRET_VALUE} in your Compose file, the value still gets injected as a plain environment variable and shows up in docker inspect. The whole point of the secrets: mechanism is to avoid that path by mounting a file instead.

    How do I pass secrets in a CI/CD pipeline without writing them to disk?
    Most CI systems (GitHub Actions, GitLab CI) let you inject secrets as masked environment variables, then have your pipeline write them to the expected secret file paths just before running docker compose up, and clean them up afterward. Avoid echoing the values in any pipeline logs.

    Is it safe to store secrets files in a Docker volume instead of a bind mount?
    You can, but it adds complexity for little benefit in most single-host setups. A bind-mounted file on the host with restrictive permissions is simpler to audit and rotate than a value living inside a named volume.

    What happens to secrets when a container is removed?
    The mounted secret file inside the container is removed along with the container’s filesystem. The source file on the host (referenced in the file: path) is untouched, so recreating the container remounts the same secret unless you’ve changed the source file.

    Wrapping Up

    Docker Compose secrets aren’t complicated once you get past the initial unfamiliarity — the pattern is always: define the secret at the top level, attach it to the services that need it, and read it from /run/secrets/<name> inside the container instead of an environment variable. Combined with proper .gitignore hygiene, restrictive file permissions, and a hardened host, this closes off one of the most common and preventable ways credentials leak out of containerized applications.

    If you’re setting this up on a fresh VPS, take the time to also review basic firewall and SSH hardening before you deploy anything with real credentials attached — a secrets-aware Compose file doesn’t help much if the host itself is wide open.

    Comments

    Leave a Reply

    Your email address will not be published. Required fields are marked *