Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 70

Secure Local Secrets with Docker Compose

Recommended resources

Welcome back! In our last lesson, we built a crucial defense mechanism by implementing rate limiting at the API Gateway. This protects our services from being overwhelmed by excessive traffic at runtime. Now, we'll shift our focus from runtime resilience to a foundational aspect of security: protecting the sensitive data our application needs to function.

This lesson is about securely managing application secrets—such as database passwords, API keys, and JWT signing keys—for local development. Hardcoding this information is a common but dangerous practice. You will learn the standard, secure way to handle these secrets using Docker Compose and environment files, ensuring your sensitive data never accidentally ends up in your version control history.

By the end of this lesson, you will be able to manage application secrets securely for local development using Docker Compose and environment files.

1. The Dangers of Exposed Secrets

First, what is a "secret"? It's any piece of configuration that grants access or is otherwise sensitive. Passwords and API keys are obvious examples. If these are exposed, the consequences can be severe.

Leaking secrets can happen easily, especially when an application is in debug mode or misconfigured. The following image shows a real-world example from a Django application where sensitive environment variables, including database credentials and AWS keys, were leaked on a public-facing error page.

Exposed Environment Variables in a Django Application
This screenshot shows a catastrophic secret leak on a Django 'DisallowedHost' error page. Notice the `AWS_SECRET_ACCESS_KEY` and `DATABASE_PASSWORD` are visible in plain text. This is what we aim to prevent.

The most common way secrets are unintentionally exposed is by hardcoding them in source code or configuration files (like docker-compose.yml) and then committing them to a Git repository.

To understand why this is such a critical issue, let's watch a short explanation.

Environment Variables in Docker Compose: Secure Your Secrets!

This video from Oluwaseun Alausa introduces the core problem of hardcoding secrets.

Watch the introduction to understand the risks. Pay close attention to the mention of GitHub's secret scanning, which actively looks for committed secrets, and the general best practice of never checking sensitive data into version control, even in private repositories.


2. The Standard Solution: Environment Files

The industry-standard solution for managing secrets in local development is to use environment files (commonly named .env).

An environment file is a simple text file that stores configuration as KEY=VALUE pairs. Your application or orchestration tool (like Docker Compose) reads this file at runtime to load the configuration. The crucial rule is: the .env file is never committed to version control. It is always listed in your project's .gitignore file.

This diagram illustrates the basic workflow:

Setting Environment Variables in Docker Compose
Docker Compose reads variables from the `.env` file and uses them to configure the services defined in `docker-compose.yml`, injecting them into the running containers.

While the concept is simple, the mechanics of how Docker Compose uses these variables can be nuanced. Let's dig into the details.


3. How Docker Compose Manages Environment Variables

Docker Compose provides several ways to get variables into your services, and understanding their differences and precedence is key to using them correctly.

Docker Compose env_file vs environment Variables

This article from Build with Matija provides an excellent, clear breakdown of how Docker Compose handles variables.

First, read the section "The Three Mechanisms" to understand the difference between: environment:: Defining variables inline in the compose.yml file, which supports interpolation. env_file:: Explicitly telling a service to load variables from a specified file. Automatic .env pickup: How Compose automatically finds and uses a .env file in the project root, primarily for substituting variables within the compose.yml file itself. Next, study "The Full Precedence Order". This is vital for debugging. It explains which source "wins" when a variable is defined in multiple places (e.g., in a .env file and also under the environment: key).

To summarize the key points from the reading:

  • A .env file in your project root is loaded automatically by Docker Compose. Its primary purpose is to provide values for variable substitution inside your docker-compose.yml file (e.g., POSTGRES_PASSWORD: ${DB_PASSWORD}).
  • The env_file: directive loads a file's contents directly into a container's environment. It does not support variable substitution within the file itself.
  • The environment: directive has the highest precedence (besides the command line) and will override values from env_file: or an image's ENV instruction.

The most important security practice, repeated in all professional guides, is ensuring your secrets file is never committed.

Your .env File Is Lying to You

This video from LearnThatStack reinforces the fundamental rule of .env files.

Watch this short segment on why .env files must be git-ignored. It makes a critical point: even if you accidentally commit a secret and then delete it in a later commit, the secret still exists in your Git history.


4. Hands-On Exercise: Securing a Service

Let's put this into practice. Below is a docker-compose.yml file with hardcoded database credentials—a clear security anti-pattern. Your task is to refactor it to use a .env file securely.

Setup:

  1. Create a new project folder.
  2. Inside it, create a file named docker-compose.yml with the content below.
docker-compose.yml (Your workspace)
version: '3.8'

services:
  db:
    image: postgres:16-alpine
    container_name: my_postgres_db
    environment:



      # TODO: Move these hardcoded secrets to a .env file
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: a_very_bad_password
      POSTGRES_DB: myapp
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

Instructions:

  1. Create a .env file in the same directory. Inside it, define the three secret variables: POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB. Assign them secure, non-default values.
    
    
    
    # .env
    POSTGRES_USER=myuser
    POSTGRES_PASSWORD=somesecurepassword123
    POSTGRES_DB=servicedb
    
  2. Modify docker-compose.yml. Change the environment section for the db service to use variable substitution to read the values from your .env file.
    Hint: Use the ${VARIABLE_NAME} syntax.
  3. Create a .gitignore file. Add the .env file to it to ensure it's never committed to version control. This is a critical step.
  4. Run and Verify. Start the service using docker compose up -d. Then, inspect the running container's environment variables to confirm it received the values from your .env file:
    docker inspect my_postgres_db | grep POSTGRES_
    
    You should see the values you set in your .env file, not the old hardcoded ones.
Solution

docker-compose.yml (modified):

version: '3.8'

services:
  db:
    image: postgres:16-alpine
    container_name: my_postgres_db
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_DB: ${POSTGRES_DB}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:

.env file (example):

POSTGRES_USER=myuser
POSTGRES_PASSWORD=somesecurepassword123
POSTGRES_DB=servicedb

.gitignore file:

.env

5. Limitations and More Secure Alternatives

Using .env files is a great practice for local development, but it's important to understand its limitations, especially as you approach production environments.

The primary weakness is that any variable passed via the environment or env_file key is stored in plain text in the container's configuration and can be easily viewed by anyone with Docker access using docker inspect.

Managing Secrets in Docker Compose

This article from Phase demonstrates the security weakness of using environment variables.

Read the first section of the article, which starts with a code block showing environment variables. Focus on the docker inspect and docker exec commands that demonstrate how easily these variables can be read from a running container. This highlights why this method isn't ideal for highly sensitive production secrets.

For more sensitive scenarios, even in development, Docker Compose offers a more secure mechanism: the top-level secrets directive. Instead of injecting secrets as environment variables, this feature mounts them as files in a temporary in-memory filesystem inside the container at /run/secrets/.

Your application can then read the secret from this file. This is more secure because:

  1. The secrets are not exposed via docker inspect.
  2. The files are read-only and exist only in memory.
  3. You can control file permissions with fine-grained access.

Many official Docker images, including Postgres, support this pattern out-of-the-box. They look for a _FILE suffix on environment variables. For example, if you set POSTGRES_PASSWORD_FILE=/run/secrets/db_password, the Postgres container will read its password from that file instead of from the POSTGRES_PASSWORD environment variable.

The following article shows how to implement this pattern.

Managing Secrets in Docker Compose

This is the same article as before, but we'll focus on the more advanced patterns.

First, read the "Prerequisites" section and pay close attention to the Python code snippet get_secret. This is a robust pattern for a Python app to prioritize reading secrets from files (the _FILE convention) while falling back to environment variables. Next, review "Approach 2: File-Based Secrets". This shows how to use the top-level secrets block in docker-compose.yml to mount a local file (e.g., ./db_password.txt) into a container's /run/secrets/ directory. This is the next logical step in securing your secrets.

While we won't implement this more advanced pattern today, it's crucial to know it exists as you move toward production-grade security.


Conclusion

You have now mastered a fundamental security practice for local development. By externalizing secrets into a git-ignored .env file, you drastically reduce the risk of accidentally exposing sensitive credentials.

Key Takeaways:

  • Never hardcode secrets in source code or configuration files that are checked into version control.
  • For local development, use a .env file to store secrets and add it to your .gitignore file.
  • Docker Compose automatically loads a .env file from the project root for variable substitution within docker-compose.yml.
  • Environment variables injected into a container are visible via docker inspect, making them less secure than file-based secrets.
  • For a more secure approach, use the top-level secrets directive in Docker Compose to mount secrets as in-memory files.

Preview of the Next Lesson

We have now covered several patterns for making our system more resilient and secure. In the next lesson, we will return to runtime resilience by exploring how to design for graceful degradation when a non-critical downstream service is unavailable. This will ensure our system remains partially functional even when parts of it fail, a core tenet of robust microservice architecture.