Secrets in Docker: Stop Hardcoding Them in Your Compose File

by Arif Ikhsanudin, Backend Developer

The password that's been in your git history for two years

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: correcthorsebatterystaple

This line, committed to your git repository at some point, is now in the git history permanently. Even if you delete it from the current file, git log -p reveals it. If the repository was ever public, or if anyone with access to the repo has read the password from the history, it's been potentially exposed.

This pattern is in the majority of Docker Compose tutorials. It makes sense for learning — environment variable injection is straightforward to demonstrate. For production, any service with real credentials configured this way has a secret management problem.

The threat model

Before choosing a solution, be clear about what you're protecting against:

  1. Repository access: anyone who can read the repository can read secrets committed to it, including historical commits
  2. Log access: environment variables are sometimes logged by applications, CI systems, or orchestrators — plaintext values in env vars are at risk
  3. Container inspection: docker inspect on a running container shows environment variables in plaintext — anyone with Docker host access can read them
  4. Image inspection: secrets baked into images via ENV or ARG are readable from the image

A .env file that's gitignored addresses threat 1. It doesn't address threats 2–4. A proper secrets manager addresses all of them.

Layer 1: .env files (minimum viable)

At minimum, remove secrets from the Compose file and put them in a .env file that's gitignored:

# .gitignore
.env
.env.*
!.env.example
# .env — gitignored
DB_PASSWORD=actual_production_password
JWT_SECRET=actual_jwt_secret
# docker-compose.yml — committed
services:
  db:
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}

Provide a committed .env.example that documents required variables without values:

# .env.example — committed, copy to .env and fill in
DB_PASSWORD=
JWT_SECRET=
API_KEY=

This stops the secret from being in git history. It doesn't stop someone with host access from reading it via docker inspect, and it doesn't help if .env is accidentally committed.

Layer 2: Docker Compose secrets

Compose has a native secrets mechanism that mounts secrets as files inside the container rather than as environment variables:

services:
  app:
    secrets:
      - db_password
      - jwt_secret
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password

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

The secret file is mounted at /run/secrets/db_password inside the container. Your application reads the file rather than an environment variable.

This requires application-side support — your app must be able to read the secret from a file path rather than an environment variable. Many applications support a _FILE suffix convention (PostgreSQL client libraries, some Spring Boot configurations), or you can add file reading at startup:

// Node.js — read secret from file if *_FILE env var is set
function readSecret(key) {
  const fileKey = `${key}_FILE`;
  if (process.env[fileKey]) {
    return require('fs').readFileSync(process.env[fileKey], 'utf8').trim();
  }
  return process.env[key];
}

const dbPassword = readSecret('DB_PASSWORD');

The advantage: secrets don't appear in docker inspect output as environment variables. The disadvantage: secrets are still on the host filesystem in plaintext files, just in a different location.

Layer 3: runtime secrets manager

For production, secrets should come from a secrets manager, not from files on the host:

HashiCorp Vault: inject secrets at container start via the Vault Agent sidecar or Vault's envconsul, which reads secrets from Vault and writes them to environment variables or files before launching the application.

AWS Secrets Manager / Parameter Store: retrieve secrets at startup using the AWS SDK or aws-env (a small tool that reads SSM parameters and sets them as environment variables):

FROM your-app-base
# aws-env reads SSM parameters and exec's into the app with them set
COPY --from=ghcr.io/remind101/ssm-env:latest /ssm-env /usr/local/bin/ssm-env
ENTRYPOINT ["ssm-env", "-with-decryption"]
CMD ["java", "-jar", "app.jar"]

Kubernetes Secrets + external-secrets-operator: Kubernetes Secrets are base64-encoded (not encrypted) by default but can be encrypted at rest with KMS. The external-secrets-operator synchronizes secrets from AWS Secrets Manager, GCP Secret Manager, or Vault into Kubernetes Secrets, which are mounted into pods:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: app-secrets
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: app-secrets
  data:
    - secretKey: db-password
      remoteRef:
        key: production/app/db-password

The resulting Kubernetes Secret is mounted as a file or environment variable in the pod. The plaintext secret never touches the CI pipeline or the host filesystem.

What NOT to do

Don't use Docker build args for secrets:

ARG DB_PASSWORD   # bad — visible in docker history, in the image layers
ENV DB_PASSWORD=$DB_PASSWORD

docker history --no-trunc your-image reveals build args. Even if you delete the ENV line, the ARG value may persist in the build layer.

The correct alternative for secrets needed during build (e.g., to access a private package registry): use BuildKit's --secret flag:

RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
docker buildx build --secret id=npm_token,src=.npm_token .

The secret is available during the build step but is not stored in any layer.

Don't print environment variables in logs:

// Don't log all environment variables at startup
System.getenv().forEach((k, v) -> log.info("{} = {}", k, v));

Even if secrets are properly managed, logging them defeats the purpose. Log only configuration keys, not values.

The practical path

If you have hardcoded secrets in Compose files right now:

  1. Move them to a .env file, add .env to .gitignore — do this today
  2. Rotate any secrets that were ever committed to git history — assume they're compromised
  3. Move toward a secrets manager for production environments — do this before the next security audit asks you to

The .env file is not a final solution — it's an improvement over committed secrets. Plan your path to runtime secret injection before it becomes a compliance requirement rather than a voluntary improvement.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Spring Security Method-Level Authorization — @PreAuthorize, SpEL, and Custom Permission Evaluators

URL-level authorization is coarse-grained — it protects paths, not resources. Method-level authorization with @PreAuthorize enables fine-grained access control that considers the current user, the method arguments, and the resource being accessed.

Read more

Prague Has World-Class Backend Engineers — SAP, Siemens and Automotive Giants Hire Them First

Czech engineering talent is genuinely strong. The enterprise companies that figured this out a decade ago have had first pick ever since.

Read more

When the Most Experienced Developer Becomes the Biggest Bottleneck

At first, having a highly experienced developer feels like a shortcut to speed. Then one day, everything starts waiting on them.

Read more

Feature Flags: Ship Code Without Releasing Features

Feature flags decouple code deployment from feature release — letting teams ship continuously while controlling what users see. They're one of the most effective tools for reducing deployment risk, and one of the most commonly misused.

Read more