# Secrets and Environment Variables in Docker

Most leaked credentials aren't the result of a clever attack. Someone ran `COPY . .` with a `.env` file in the folder and pushed the image. Someone pasted the output of `docker compose config` into a GitHub issue. Someone logged `process.env` while debugging and forgot to remove it.

Docker makes it very easy to pass configuration into a container, and just as easy to pass it in the wrong way. This post walks through where secrets tend to leak, and what a safer setup looks like for a typical Node.js app with a Postgres database, a background worker, and a few other services around it.

## Config vs. Secrets

Not everything in your `.env` file is sensitive. It helps to separate the two:

-   **Config** is harmless if it leaks: `NODE_ENV`, `PORT`, `LOG_LEVEL`, feature flags. Put it wherever is convenient.
    
-   **Secrets** are things that would force you to rotate them if someone saw them: database passwords, API keys, JWT signing keys, webhook secrets, and tunnel tokens.
    

A simple test: if exposing this value means you'd have to change it right away, treat it as a secret. Everything below is about that second group.

## How Environment Variables Leak

Environment variables are the default way to configure a container, and they're fine for config. For secrets, they have some uncomfortable properties:

-   **They're visible to anyone who can inspect the container.** `docker inspect` prints every variable in plain text, and so does `/proc/<pid>/environ` inside the container.
    
-   **They're inherited by child processes.** Any script or tool your app spawns gets the full environment.
    
-   **They end up in logs and error reports.** A debug statement, a crash dump, or an error tracker that captures the environment will happily include them.
    
-   **They show up in tooling output.** `docker compose config` prints the fully resolved file, including any secrets passed as variables.
    

Try it yourself on a running container:

```bash
docker inspect --format '{{.Config.Env}}' my-api
```

If your database password is in that output, anyone with access to the Docker socket on that machine can read it. That's the trade-off you accept with env vars, so decide on purpose rather than by accident.

## Never Bake Secrets into an Image

This is the most damaging mistake, because images get copied, pushed, cached, and shared far more widely than running containers.

```dockerfile
# Don't do this
FROM node:24-alpine
ENV DATABASE_URL=postgres://app:supersecret@db:5432/app
ARG NPM_TOKEN
COPY . .
```

There are three separate problems here:

-   `ENV` **is permanent.** The value is stored in the image metadata, and anyone who can pull the image can read it.
    
-   `ARG` **is not a safe hiding place.** Build arguments can show up in the image history, so passing a token with `--build-arg` often just moves the leak.
    
-   `COPY . .` **copies everything**, including a `.env` file sitting in your project folder.
    

Fix the last one with a `.dockerignore`:

```
.env
.env.*
!.env.example
.git
*.pem
secrets/
```

And remember that deleting a file in a later layer doesn't remove it from the earlier one. If a secret was ever baked into an image that left your machine, rotate it.

## Compose: `environment`, `env_file`, and the `.env` Gotcha

In Docker Compose there are several ways to pass variables, and people regularly mix them up:

```yaml
services:
  api:
    image: my-api
    env_file:
      - .env.production
    environment:
      NODE_ENV: production
      DATABASE_URL: ${DATABASE_URL}
```

-   `environment` sets variables directly in the Compose file. If the same key appears in `env_file`, the `environment` value wins.
    
-   `env_file` loads variables from a file and injects them into the container.
    
-   `${VAR}` **interpolation** fills in values inside the Compose file itself, taken from your shell or from a file named `.env` next to it.
    

The gotcha is that the `.env` file Compose reads automatically is used for interpolation only. Its contents are not sent to your containers unless you reference them through `environment` or `env_file`. Plenty of people assume the opposite and spend an hour wondering why a variable is undefined.

Keep a committed `.env.example` listing every variable name with fake values, and keep the real `.env` out of Git. New teammates (or a new server) can copy the example and fill in the blanks.

## File-Based Secrets: A Better Default

For real secrets, mount them as files instead of passing them as variables. Compose has first-class support for this:

```yaml
services:
  api:
    build: .
    environment:
      NODE_ENV: production
      DATABASE_URL_FILE: /run/secrets/database_url
      JWT_SECRET_FILE: /run/secrets/jwt_secret
    secrets:
      - database_url
      - jwt_secret
    depends_on:
      - db

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: app
      POSTGRES_DB: app
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pgdata:/var/lib/postgresql/data

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

volumes:
  pgdata:
```

Each secret appears inside the container as a read-only file at `/run/secrets/<name>`. The values no longer show up in `docker inspect`, and they aren't inherited by every child process.

The official Postgres image supports this pattern out of the box through the `_FILE` suffix, as shown with `POSTGRES_PASSWORD_FILE`. Many other popular images follow the same convention.

Be realistic about what this gives you. In plain Compose (without Swarm), a file secret is a bind mount of a file on the host. It isn't encrypted, so the host's file permissions still matter. Keep the `secrets/` folder out of Git, outside any web-served directory, and restrict it with something like `chmod 600`. Just make sure the user your container runs as can still read the files, because a file locked to your host user causes permission errors inside the container. Docker Swarm secrets go further by storing them encrypted and mounting them in memory, but that's a different setup.

### Reading `_FILE` Secrets in Node.js

Your own app can follow the same convention with a tiny helper:

```typescript
// secrets.ts
import { readFileSync } from "node:fs";

export function readSecret(name: string): string | undefined {
  const path = process.env[`${name}_FILE`];
  if (path) return readFileSync(path, "utf8").trim();
  return process.env[name];
}
```

It prefers the file when `DATABASE_URL_FILE` is set and falls back to a plain `DATABASE_URL` variable, which keeps local development simple. Pair it with startup validation so a missing secret stops the app immediately instead of failing on the first request:

```typescript
import * as z from "zod";
import { readSecret } from "./secrets";

const Env = z.object({
  DATABASE_URL: z.url(),
  JWT_SECRET: z.string().min(32),
});

export const env = Env.parse({
  DATABASE_URL: readSecret("DATABASE_URL"),
  JWT_SECRET: readSecret("JWT_SECRET"),
});
```

If you run a background worker from the same image, give it the same helper and only the secrets it actually needs. A worker that sends emails has no business holding your JWT signing key.

## When a Tool Only Accepts Environment Variables

Some software gives you no choice. Many tunnel and agent tools expect a token in an environment variable, for example a Cloudflare tunnel token. In that case:

-   Check the tool's docs first, since some support reading a token from a file.
    
-   Keep the value in a root-owned file outside Git and reference it with `env_file` rather than writing it into the Compose file.
    
-   Restrict who can run Docker commands on the host, because that access is effectively access to the variable.
    
-   Give each token the narrowest scope the provider allows, so a leak has a small blast radius.
    

It's not perfect, but it's a reasonable compromise when the alternative is not using the tool.

## Build-Time Secrets

Sometimes the secret is needed during the build, for instance an npm token to install a private package. Use BuildKit secret mounts. The secret is available only for that single `RUN` step and is never written into a layer:

```dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci
COPY . .
```

And pass it when building:

```bash
docker build --secret id=npmrc,src=$HOME/.npmrc -t my-api .
```

Compare that to `ARG NPM_TOKEN`, which leaves the token in the build history.

## Keep Secrets Out of Your Logs

If you read container logs with `docker logs` or a web viewer like Dozzle, remember that those tools show everything your app prints, to everyone who can open them. A secret that reaches `stdout` is a secret that's on a dashboard.

The usual ways it gets there:

-   Logging the whole environment or config object at startup
    
-   Logging full request objects, including `Authorization` headers and cookies
    
-   Printing connection strings in error messages
    

If you use Pino, the `redact` option masks sensitive paths before they're written:

```typescript
import pino from "pino";

export const logger = pino({
  redact: ["req.headers.authorization", "req.headers.cookie", "password", "*.password", "token", "*.token"],
});
```

Also be careful where you paste the output of `docker compose config` or `docker inspect`. They're great for debugging and terrible for sharing.

## Git Hygiene and What to Do After a Leak

Prevention is mostly boring habits:

-   Add `.env`, `.env.*`, and your `secrets/` folder to `.gitignore`, and keep `.env.example` committed.
    
-   Run a secret scanner such as gitleaks or TruffleHog as a pre-commit hook or in CI.
    
-   Never put real credentials in a Dockerfile, a Compose file you commit, or a README.
    

If a secret does leak, the order matters:

1.  **Rotate it first.** Create a new credential and revoke the old one.
    
2.  **Then clean up.** Remove it from the repo, history, and images.
    
3.  **Check for misuse.** Look at access logs from the provider for the period it was exposed.
    

Removing a secret from Git history without rotating it doesn't help. Assume anyone who could have seen it already copied it.

## Rotation and Growing Beyond Files

With environment variables, changing a secret means recreating the container, and `docker compose up -d` does that for you when the configuration changes. File-based secrets need one extra step: Compose doesn't notice when a secret file's contents change, so restart or recreate the service (for example with `docker compose up -d --force-recreate`) so the app reads the new value.

Postgres has its own catch. The official image only reads `POSTGRES_PASSWORD_FILE` when it initializes an empty data volume, so changing the secret later doesn't change the password inside an existing database. Rotate it inside Postgres first (with `ALTER ROLE`), then update the secret and restart the services that use it.

For a single server running a handful of services, file-based secrets with tight permissions are a solid baseline. As things grow, such as multiple servers, teams, audit requirements, or frequent rotation, look at a proper secrets manager. Options include Doppler, Infisical, HashiCorp Vault, SOPS for encrypted files in Git, 1Password's CLI, and the managers built into the major cloud providers. They add access control, audit logs, and rotation, which flat files can't give you.

## A Quick Checklist

-   Separate config from secrets, and treat anything you'd have to rotate after a leak as a secret.
    
-   Never use `ENV`, `ARG`, or `COPY .env` to put secrets into an image, and keep a `.dockerignore`.
    
-   Remember that `.env` in Compose is for interpolation, while `env_file` is what injects variables.
    
-   Prefer file-based secrets (`/run/secrets/...` and `_FILE` variables) for real credentials.
    
-   Use BuildKit secret mounts for build-time secrets.
    
-   Validate secrets at startup so the app fails fast when one is missing.
    
-   Redact sensitive fields in logs, especially if you use a log viewer.
    
-   Scan for secrets in Git, and rotate first if one ever leaks.
    

None of this requires a big platform or a budget. A handful of habits, applied consistently, removes the most common ways credentials escape. What does your current setup do with its database password? It's a useful thing to check today.

---

*Published via [ZyVOP](https://zyvop.com/secrets-and-environment-variables-in-docker-n823x?utm_source=hashnode&utm_medium=crosspost&utm_campaign=syndication) — Write once in Markdown, auto-backup to GitHub, and syndicate to Dev.to, Medium & Hashnode in 1 click.*
