Syncing Doppler secrets to local Docker containers

Local development drifts from production the moment someone hand-copies secrets into a .env and forgets to update it. Doppler closes that gap by injecting the same secrets into your container at runtime. This page wires it up for a Python service in Docker, extending Doppler for Multi-Cloud Secrets.

The specific difficulty in Docker is that there are two environments, not one. doppler run populates the environment of the process it wraps — which is docker compose, not the container — so getting values across that boundary requires one deliberate step. Most confusion about this setup comes from assuming the injection is transitive when it is not.

Two environments separated by the container boundary doppler run populates the environment of the compose command on the host, and a passthrough entry is required for those values to reach the container process. injection is not transitive across the boundary host process doppler run -- docker compose up values live here the compose CLI can see them passthrough container process environment: - API_KEY os.environ inside the app nothing arrives without the entry
The compose file must name each variable it forwards — this is the step people skip and then debug for an hour.

Problem 1: a hand-maintained .env in the image

# ANTI-PATTERN: secrets baked into the image, drifting from prod
COPY .env /app/.env          # now in image history forever, and stale within a day

The secret is in the image layers and diverges from the real values the moment they rotate. Both halves of that are serious and the second is the one people underestimate: a value copied into an image is a snapshot, so every rotation makes the image progressively more wrong while it continues to run perfectly on whatever it captured.

The usual cause is not an explicit COPY .env but a broad COPY . . with no .dockerignore entry, since the file is untracked but still in the build context. Git ignoring it does nothing here — .gitignore and the build context are unrelated mechanisms — and layers are additive, so deleting the file in a later step hides it from the final filesystem while leaving it readable in the layer beneath.

Problem 2: passing the token on the command line

# ANTI-PATTERN: token leaks into shell history and ps output
docker run -e DOPPLER_TOKEN=dp.st.dev.xxxx myapp

The token is now in shell history and visible in the process list to anyone on the host. Shell history is the more persistent of the two: .bash_history survives reboots, is rarely reviewed, and is exactly the kind of file that gets included in a backup or a screen share.

The process-list exposure is briefer and broader. On a shared machine any user can read another’s command lines from /proc, so a token passed as an argument is visible to every account on the host for the lifetime of the command. Passing values through the environment rather than as arguments avoids both, which is why doppler run exists in the shape it does.

Where a token passed as an argument ends up A command-line token is recorded in shell history and visible in the process list, while an environment-injected token is neither. -e TOKEN=… on the command line written to .bash_history visible in ps and /proc survives reboots and backups readable by other accounts doppler run -- … token comes from your login passed via the environment nothing in history nothing in the process list
Arguments are public on a shared host; the environment is not, which is the entire reason for the wrapper.

Secure implementation

# app/entrypoint.py — fetch at startup, never bake into the image
import os
import requests
from pydantic import SecretStr

def load_doppler() -> dict[str, SecretStr]:
    token = os.environ["DOPPLER_TOKEN"]          # injected by the runtime, not the image
    resp = requests.get(
        "https://api.doppler.com/v3/configs/config/secrets/download",
        params={"format": "json"}, auth=(token, ""), timeout=5,
    )
    resp.raise_for_status()
    return {k: SecretStr(v) for k, v in resp.json().items()}
# Run the container under the Doppler CLI; it injects DOPPLER_TOKEN from your login,
# never the image, never shell history.
doppler run --config dev -- docker compose up

doppler run supplies the scoped dev token to the container at launch. The image contains no secrets, and the same code path runs in CI and production with a different config.

The missing piece is the passthrough. doppler run populates the host environment for the docker compose command, and Compose forwards only what the service declares:

# compose.yaml — name each variable the container should receive
services:
  app:
    build: .
    environment:
      - DOPPLER_TOKEN            # no value: forwarded from the host environment

The value-less form is the important detail. - DOPPLER_TOKEN tells Compose to take whatever the host has under that name, so nothing is written into the file and nothing needs updating when the token rotates. Writing DOPPLER_TOKEN: ${DOPPLER_TOKEN} achieves the same result more verbosely; writing a literal value puts a credential in a tracked file.

Forwarding only the token, rather than every secret, is what keeps this clean. The container receives one credential and fetches the rest itself, so the compose file lists exactly one single variable regardless of how many secrets the service needs — and adding a secret in Doppler requires no local change at all.

Forward one token, fetch the rest inside the container Only the Doppler token crosses into the container, and the application fetches the remaining secrets itself, so the compose file lists one variable regardless of how many secrets exist. one variable crosses the boundary, whatever the secret count host DOPPLER_TOKEN only container fetches the rest itself Doppler API dev config values Adding a secret in Doppler needs no change to the compose file, the image, or anyone's machine.
Forwarding the token rather than the values keeps the compose file at one entry forever.

Keeping the container startable when Doppler is not

A container that cannot start without reaching the Doppler API is fine most days and frustrating on the day it matters — an offline flight, a hotel network, a Doppler incident. Local development is exactly where a hard external dependency is least welcome, and where a small escape hatch costs nothing.

# app/entrypoint.py — Doppler first, local fallback second, never in production
import json, os
from pathlib import Path
from pydantic import SecretStr

FALLBACK = Path.home() / ".cache" / "myapp" / "dev-secrets.json"

def load_secrets() -> dict[str, SecretStr]:
    try:
        values = load_doppler()
        if os.environ.get("APP_ENV") == "development":
            FALLBACK.parent.mkdir(parents=True, exist_ok=True)
            FALLBACK.write_text(json.dumps({k: v.get_secret_value()
                                            for k, v in values.items()}))
            FALLBACK.chmod(0o600)                  # owner-readable only
        return values
    except Exception:
        if os.environ.get("APP_ENV") != "development" or not FALLBACK.exists():
            raise                                  # never fall back in production
        return {k: SecretStr(v) for k, v in json.loads(FALLBACK.read_text()).items()}

Three constraints keep this from becoming the .env file it replaces. The cache is written only when APP_ENV is development, so nothing resembling it ever exists in another environment. It lives outside the repository, under the user’s cache directory, so no .gitignore rule has to be trusted and no build context can include it. And it is 0600, so other accounts on the machine cannot read it.

The fallback path re-raises unless the environment is explicitly development, which is the line that matters most. Production must fail loudly when the secret store is unreachable rather than silently using a cached copy of unknown age — a stale credential serving traffic is a worse outcome than a failed start.

Worth being clear about the trade-off: this is a plaintext copy of development secrets on a laptop, refreshed each time Doppler is reachable. That is acceptable precisely because the values are development-scoped and worth nothing outside the developer’s own environment. Applying the same pattern to a production config would recreate every problem Doppler was adopted to solve.

Offline fallback bounded to development only In development an unreachable Doppler falls back to an owner-readable cache outside the repository, while in any other environment the failure propagates. Doppler unreachable — then what? APP_ENV=development cache outside the repo, chmod 600 dev-scoped values only refreshed whenever Doppler works the flight still works any other environment the exception propagates no cache is ever written startup fails visibly never serve an unknown-age value
The escape hatch is bounded by environment, by file permissions, and by location — remove any one and it becomes a .env file.

Making it the path of least resistance

A secrets setup that developers work around has failed regardless of how correct it is, and the workaround is always the same: someone writes a .env because it is faster than remembering the command. Removing that incentive is mostly a matter of shortening the happy path.

# Makefile — one target, nobody has to remember the invocation
.PHONY: up test shell

up:
	doppler run --config dev -- docker compose up

test:
	doppler run --config dev -- docker compose run --rm app pytest

shell:
	doppler run --config dev -- docker compose run --rm app bash

make up is shorter than docker compose up, which means the correct route is also the convenient one — and that, rather than any policy, is what stops people inventing alternatives. Every target goes through doppler run, so nobody has to remember which commands need it.

A doppler.yaml in the repository removes the other friction point by pinning the project and config, so doppler run works without flags after a one-time doppler setup:

# doppler.yaml — committed; contains no secrets, only which config to use
setup:
  project: payments-api
  config: dev

That file is safe to commit precisely because it names a config rather than containing values, and having it in the repository means a new developer’s setup is doppler login, doppler setup, make up — three commands, none of which involve asking a colleague for values.

The README matters more than usual here. A developer who cannot start the service in five minutes will find a way that works, and that way is a .env file that a colleague sends them over a chat client nobody audits. Documenting the three commands prominently is a cheaper control than any amount of policy about what must not be committed.

Onboarding path with and without the shortcuts Without a documented make target and a committed doppler.yaml a new developer asks a colleague for values, while with them the setup is three commands. what a new developer actually does on day one without the shortcuts app fails to start, keys missing asks a colleague in chat receives a .env, pastes it in credentials now in two more places with them doppler login doppler setup make up nothing was ever sent to anyone
The left column is a policy problem created by a five-minute onboarding gap — the right column closes it.

Gotchas & version-specific behaviour

  • Use a dev-scoped service token locally; never the production config.
  • doppler run injects into the immediate process — for Compose, ensure the variable propagates to the service (environment: passthrough).
  • Set a request timeout so a Doppler outage fails fast instead of hanging the container.
  • Keep the token out of docker history — inject at runtime, never ENV or ARG.
  • A container on a bridge network still reaches the public Doppler API, but a fully isolated network (network_mode: none) does not — the fallback above is what makes that case workable.
  • doppler run reads your CLI login, so a developer who has not run doppler login gets a confusing empty environment rather than an error.

That last one is worth guarding against in the entrypoint, since the symptom — an application complaining about a missing key — points nowhere near the actual cause. Checking for DOPPLER_TOKEN explicitly and exiting with a message that names doppler login turns a puzzling five minutes into an instruction the developer can follow immediately, which is a disproportionate return on one if statement.

The ARG warning deserves emphasis because build arguments feel like the natural place for a value that varies per build. They are recorded in the image metadata and readable with docker history, so a token passed as ARG is baked into anything you push — the same exposure as COPY .env, arrived at by a different route and much easier to miss in review because no file is involved and the line looks like ordinary build configuration.

Production parity checklist

  • No .env or secret is copied into the image.
  • The service token is dev-scoped and injected at runtime.
  • The same fetch code runs locally, in CI, and in production.
  • Fetched values are wrapped in SecretStr.
  • Each environment maps to a distinct Doppler config.
  • .dockerignore excludes any local secret cache, so it can never enter a build context.

The third item is the one that makes the rest worth doing. If local development used a different code path — a .env reader locally, a Doppler fetch in production — then the production path would only ever be exercised in production, which is the situation this whole setup exists to avoid. One fetch function, one settings model, and only the config name changing between environments is what turns local testing into evidence about production behaviour.

The .dockerignore item is a two-line change with an outsized effect. Add the local cache path alongside .env, .git, and __pycache__, and no build context can pick up a secret regardless of what a COPY instruction says. It is worth doing even though the cache lives outside the repository by design, because the next person to move it — reasonably, to keep the project self-contained — will not think about the build context, and the ignore entry protects against that change rather than the current one.

Verifying the whole arrangement takes a minute. Run docker history --no-trunc on the image and search for anything resembling a credential; start the container with no DOPPLER_TOKEN at all and confirm it fails clearly rather than starting half-configured; and run docker compose config to see exactly which variables the service will receive. Those three checks cover the image, the failure path, and the passthrough respectively.

Key takeaways

doppler run injecting a dev-scoped token gives local Docker containers production-identical secrets without a committed file. Two details make it actually work: a value-less environment: entry in the compose file, because injection does not cross the container boundary by itself, and forwarding only the token so the container fetches the rest and the compose file never grows.

Add a development-only offline cache if working without connectivity matters, bounded by environment, file permissions, and a location outside the repository — and make production re-raise rather than fall back, because an unreachable secret store should stop a deployment rather than quietly serve a value of unknown age.

The rest is making the correct path the easy one: a make target so nobody types the raw command, a committed doppler.yaml so setup is two commands rather than a conversation, and a README that shows all of it in the first screen. Those cost nothing and remove the reason anyone would reach for a .env file in the first place. For CI usage of the same mechanism, see Doppler Service Tokens in CI Pipelines.