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.
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.
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.
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.
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.
Gotchas & version-specific behaviour
- Use a dev-scoped service token locally; never the production config.
doppler runinjects into the immediate process — for Compose, ensure the variable propagates to the service (environment:passthrough).- Set a request
timeoutso a Doppler outage fails fast instead of hanging the container. - Keep the token out of
docker history— inject at runtime, neverENVorARG. - 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 runreads your CLI login, so a developer who has not rundoppler logingets 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
.envor 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.
.dockerignoreexcludes 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.