Managing Secrets ā€” in Docker Compose A Developer's Guide

A practical guide to securely managing secrets in Docker Compose for local development and small production deployments

Wednesday, January 8, 2025

Containers at a port Photo by Shuttersnap on Unsplash

Introduction

It's truly remarkable how much the direction of software engineering is dictated by inertia. Brendan Eich in 1995 designed JavaScript to be a client side scripting language of choice for the Netscape browser, over the years it has evolved to be on the client, server and other technologies like serverless. Similarly, Docker Compose has evolved from a local development tool into a popular choice for deploying applications, even in production environments. While Docker has published guidelines for using Compose in production, one critical aspect often overlooked by users is secure secret management.

In this guide, we'll explore the best practices for managing secrets in modern Docker Compose deployments and discuss common pitfalls to avoid. We'll progressively build up from basic approaches to more secure configurations.

The Problem with Environment Variables

Most Docker Compose setups handle secrets in one of two ways: either by hardcoding them directly in the compose file or using a .env file:

services:
  api:
    image: my-api
    environment:
      - API_KEY=super_secret_key  # šŸš« Don't do this - Hard-coding secrets in docker compose config
      - DB_PASSWORD=${DB_PASSWORD} # šŸš« Also problematic - Referencing secrets from .env

While convenient, this approach has several security implications:

  1. Environment variables are accessible to all processes in a container
  2. They often appear in logs during debugging
  3. They can be exposed through application errors
  4. They make it difficult to maintain separation of concerns between services

Let's demonstrate why this is problematic. With a basic Postgres container running:

$ docker ps
CONTAINER ID   IMAGE         COMMAND                  CREATED         STATUS         PORTS                    NAMES
ed4914af396a   postgres:16   "docker-entrypoint.sā€¦"   9 seconds ago   Up 8 seconds   0.0.0.0:5432->5432/tcp    db-1

We can easily inspect all environment variables:

$ docker inspect ed4914af396a | jq '.[] | .Config.Env'
[
  "DB_NAME=XP1_LM",
  "DB_USER=postgres",
  "POSTGRES_PASSWORD=6c37810ec6e74ec3228416d2844564fceb99ebd94b29f4334c244db011630b0e",
  ...
]

We can also directly print all environment variables from within the container:

$ docker compose exec db /bin/printenv
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/lib/postgresql/16/bin
HOSTNAME=ed4914af396a
TERM=xterm
DB_NAME=XP1_LM
DB_USER=postgres
POSTGRES_PASSWORD=6c37810ec6e74ec3228416d2844564fceb99ebd94b29f4334c244db011630b0e
GOSU_VERSION=1.17
LANG=en_US.utf8
PG_MAJOR=16
PG_VERSION=16.6-1.pgdg120+1
PGDATA=/var/lib/postgresql/data
HOME=/root

This exposure of secrets through environment variables has led to numerous security incidents over the years:

Django app debug mode error leaking secrets

Although the first two examples presume an application misconfiguration and most modern web application frameworks try their best to censor secrets in error logs, this can be a pretty serious issue.

Better Secret Management with Docker Compose

Let's explore three progressively more secure approaches to managing secrets in Docker Compose.

Prerequisites

First, ensure you're running Docker Compose version 2.30.0 or later for full secrets support:

$ docker compose version
Docker Compose version v2.32.1

Your application should also be configured to read secrets from files rather than environment variables. Here's a pattern we recommend for python, as an example:

def get_secret(key: str) -> str:
    # Check for _FILE suffix first
    file_env = f"{key}_FILE"
    if file_env in os.environ:
        with open(os.environ[file_env], 'r') as f:
            return f.read().strip()
    # Fall back to environment variable
    return os.environ.get(key)

This pattern:

  • Prioritizes reading secrets from files using the _FILE suffix convention
  • Maintains compatibility with environment variables as a fallback
  • Follows conventions used by official images like MySQL and Postgres

Example:

  1. For a given secret POSTGRES_PASSWORD check if POSTGRES_PASSWORD_FILE environment variable containing a file path exists, if yes - read the POSTGRES_PASSWORD secret from the file
  2. Else, read the secret from the POSTGRES_PASSWORD environment variable

For reference you can go through our Python implementation for Django and TypeScript for Next.js.

Approach 1: Environment Variables - Mount secrets inside your containers based on the values of host environment variables

The following implementation uses Docker Compose's secrets feature to read environment variables from the host and mount them as files via a virtual filesystem in each of your services:

services:
  api:
    build: .
    environment:
      - API_PORT=3000  # Non-sensitive config
      - API_KEY_FILE=/run/secrets/api_key
    secrets:
      - api_key
      - db_password
    command: ["./wait-for-secrets.sh", "node", "server.js"]

  db:
    image: postgres:latest
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data

secrets:
  api_key:
    environment: 'API_KEY'
  db_password:
    environment: 'POSTGRES_PASSWORD'

volumes:
  pgdata:

This mounts secrets as read-only virtual filesystem under /run/secrets/:

$ ls -l /run/secrets/db_password
-r--r--r--. 1 root root 64 Jan 7 09:23 /run/secrets/db_password

Advantages:

  • Easy setup - Simply mount the values of environment variables in memory as a filesystem
  • Secrets never written to disk - Secrets remain in memory, reducing attack surface from filesystem access
  • Better isolation between services - Each service only receives the secrets provisioned to it
  • Read-only mounting - Prevents accidental or malicious corruption of secrets by containers

Disadvantages:

  • Secrets exposed as host environment variables - Secrets must exist as environment variables on the host system - This can be addresses by using runtime secret injection via a secret manager such as Phase.
  • World-readable within container - Any user, user group or process within the container can read the secrets (addressed in the next section)
  • Requires service restart for updates - Changes to secrets require restarting affected services to take effect

Using things like export on your host system to set secrets as environment can create other unwanted externalities like your secrets getting logged in your shell history. You can use the Phase CLI to to improve the overall secret management workflow by injecting secrets directly inside the docker compose process during runtime. Here's an example:

phase run docker compose up -d

Approach 2: File-Based Secrets - Mount secrets on the host system inside your container

The following implementation uses Docker Compose's secrets feature to mount files containing secrets from the host in each of your services:

services:
  api:
    build: .
    environment:
      - API_PORT=3000
      - API_KEY_FILE=/run/secrets/api_key
    secrets:
      - api_key
      - db_password
    command: ["./wait-for-secrets.sh", "node", "server.js"]

  db:
    image: postgres:latest
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data

secrets:
  api_key:
    file: /app/api_key.txt
  db_password:
    file: ./db_password.txt

volumes:
  pgdata:

Advantages:

  • Better isolation between services - Each service only receives the secrets provisioned to it
  • Dynamic secret updates without restarts - Services can read updated secrets without container restarts
  • Inherits host file permissions - Secret files maintain their permission attributes from the host system
  • Read-only mounting - Prevents accidental or malicious corruption of secrets by containers

Disadvantages:

  • Secrets written to disk on the host system - Creates potential security risk from filesystem access or backups
  • Requires secure file management - Additional operational overhead to secure secret files
  • World-readable by default - All users/processes in container can read secrets unless explicitly restricted - Addressed in the next section

To make creation of secrets on the host system easier and to improve the overall secret management workflow you can use the Phase CLI. Here's an example:

phase secrets get API_KEY --path app/ | jq -r .value > /app/api_key.txt
phase secrets get DATABASE_PASSWORD | jq -r .value > db_password.txt
docker compose up -d

Controlling access to secrets supplied to your services

Now that we have figured out how to supply secrets securely to your services, next let's take a look how at how we can better protect them once provisioned inside our containers:

Docker Compose supports what they call a 'long syntax' for declaring how secrets are provisioned and controlling their access with more granularity within the respective service's containers.

  • source: The name of the secret as it exists on the platform.
  • target: The name of the file to be mounted in /run/secrets/ in the service's task container, or absolute path of the file if an alternate location is required. Defaults to source if not specified.
  • uid and gid: The numeric UID or GID that owns the file within /run/secrets/ in the service's task containers. Default value is USER running container.
  • mode: The permissions for the file to be mounted in /run/secrets/ in the service's task containers, in octal notation. The default value is world-readable permissions (mode 0444). The writable bit must be ignored if set. The executable bit may be set.

You can find the uid and the gid of for a given image by looking at the Dockerfile or if it's your own image add one.

Here's a postgresql example:

FROM debian:bookworm-slim

# explicitly set user/group IDs
RUN set -eux; \
	groupadd -r postgres --gid=999; \
# https://salsa.debian.org/postgresql/postgresql-common/blob/997d842ee744687d99a2b2d95c1083a2615c79e8/debian/postgresql-common.postinst#L32-35
	useradd -r -g postgres --uid=999 --home-dir=/var/lib/postgresql --shell=/bin/bash postgres; \
# also create the postgres user's home directory with appropriate permissions
# see https://github.com/docker-library/postgres/issues/274
	install --verbose --directory --owner postgres --group postgres --mode 1777 /var/lib/postgresql

Reference

You can use the Unix file permission calculator to generate a suitable mode in octal notation: https://wintelguy.com/permissions-calc.pl

services:
  db:
    image: postgres:latest
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - source: db_password
        target: db_password
        uid: "999"  # Postgres user ID
        gid: "999"  # Postgres group ID
        mode: "0440"  # Read-only for user/group

secrets:
  db_password:
    environment: 'POSTGRES_PASSWORD'

This configuration:

  • Restricts secret access to specific users/groups
  • Prevents other users from reading secrets
  • Maintains write protection

You can verify the permissions:

$ docker compose exec db ls -l /run/secrets/db_password
-rw-rwx---. 1 postgres postgres 15 Jan 7 13:52 /run/secrets/db_password

For more information on the Docker Compose secrets long syntax, please see the Docker docs

Closing thoughts

While this is a good start for your docker compose secrets, below are some of the things that you should most consider when dealing with informational fissile materials like secrets:

  1. Keep secrets away from source code, container files and images. Ivory and ebony, AC/AD and secrets and source code and container images; never the two shall meet.

  2. Control access to secrets and keep them in sync and up to date with the rest of your team and deployments securely. Please do not add secrets to your git repository, drop your .env files over Slack or add them to your Notion docs as part of getting started with a project.

  3. Don't reuse secrets across different environments. Your production database password should never be the same as the pa$$w0rd@123 that you are using for local development. Compromise of one will mean de-facto compromise of all.

  4. Encrypt secrets at all times, whether they are in flight over a network making they way to your production deployment or waiting in a database patiently to be pulled. "Dance like no one is watching, but encrypt like everyone is" - Werner Vogels, Chief Technical Officer Amazon.

  5. Keep track of all changes and actions over your secrets. It's 4:30 in the morning, do you know where your secrets are? Keep tabs on the who, what, when, and where so you can infer the why if and when there is an incident. Given the outsized number of breaches that occur due to a secret compromise, you will need those audit logs during an investigation.

Some or all of these points may seem obvious to most of you reading this, but what may not be as obvious is how tricky and tedious it can be to follow security best practices without losing development velocity. Consider using open-source secrets management platform like Phase which can help streamline the process. We are a bit biased plugging our own solution, but we think you'll find it useful.

Conclusion

Docker Compose's secret management capabilities have matured significantly, offering features typically found in larger container orchestration systems. While there are still some areas of improvements and limitations around permission enforcement (see docker/compose#12362), the available options provide a solid foundation for securing secrets in both development and smaller production environments.

CLOUD

The fastest and easiest way to get started with Phase. Spin up an app in minutes. Hosted in Frankfurt šŸ‡©šŸ‡Ŗ

SELF-HOSTED

Run Phase on your own infrastructure and maintain full control. Perfect for customers with strict compliance requirements.