Load Pydantic Settings from Docker Secrets
Docker and Swarm mount secrets as files under /run/secrets, not as environment variables — which is deliberately safer, because files on a tmpfs are not exposed by docker inspect the way env vars are. pydantic-settings reads this layout natively through secrets_dir, so you can wire mounted secrets into a validated model with one line. This extends the pydantic-settings fundamentals and the secrets management patterns.
The file-versus-environment distinction is the whole reason Docker secrets exist. An environment variable is inherited by every child process, printed by a careless docker inspect or /proc/<pid>/environ read, captured in the container’s configuration in the orchestrator’s API, and — if it was set in the Dockerfile — baked permanently into the image’s layer history. A file mounted on a tmpfs has none of those exposure surfaces: it exists only in the running container’s memory-backed filesystem, is scoped to the processes that can read that path, and vanishes when the container stops. pydantic-settings meets this model exactly by treating a directory of files as just another source, so the secure Docker layout costs you a single secrets_dir line and gives back a fully validated, masked settings object.
Problem 1: passing secrets as environment variables
# ANTI-PATTERN — secret in an env var, visible to docker inspect
import os
db_password = os.environ["DB_PASSWORD"]
Anyone with docker inspect access to the container reads the value, and it is trivially leaked into a log line. The exposure is broader than most people realise. An environment variable set on a container is visible in at least five places: docker inspect prints it in the container’s config; /proc/<pid>/environ exposes it to anything that can read that path inside the container; every child process the app spawns inherits it, so a shelling-out subprocess carries the credential into places you never intended; the orchestrator’s API (Swarm, Kubernetes) stores it in the object definition, where it shows up in kubectl get pod -o yaml and audit exports; and if the value was ever written into the Dockerfile with ENV, it is permanently embedded in that image layer and travels with the image to every registry and every machine that pulls it.
That last one is the trap that turns a momentary convenience into a durable breach: a secret baked into an image layer cannot be removed by deleting it in a later layer, because the earlier layer still contains it and anyone with the image can extract it. Environment variables are the right transport for configuration — hostnames, flags, timeouts — precisely because that visibility is harmless there. For credentials, the same visibility is the vulnerability, which is why Docker provides a separate, file-based mechanism for secrets and why routing them through it is the baseline, not an optimisation.
Problem 2: reading the secret file by hand
# ANTI-PATTERN — manual file read, no validation, no masking
with open("/run/secrets/db_password") as fh:
db_password = fh.read() # trailing newline, plain str, unvalidated
Hand-reading each file misses validation, leaves a trailing newline, and stores the credential as a plain str that can leak. The trailing-newline bug is the one that wastes an afternoon. Many tools write secret files with a terminating newline, so open(path).read() returns "hunter2\n" rather than "hunter2", and the authentication fails with a “wrong password” error that sends you checking the credential everywhere except the invisible newline at its end. You end up adding a .strip() — which is the kind of easily-forgotten defensive step that pydantic-settings performs for you when it reads a secrets file.
Beyond the newline, the manual read reproduces every problem the settings model was built to solve. The value is a plain str, so it is not masked in repr(), logs, or tracebacks and can leak through any of them. There is no validation, so a truncated or empty file becomes a silent misconfiguration discovered at connection time rather than startup. And the pattern does not scale: each secret needs its own open/read/strip block, so a service with eight credentials grows eight nearly-identical stanzas that all have to be written and kept in sync by hand. Declaring the fields on a model with secrets_dir set replaces all of that with a type annotation apiece.
Secure implementation
Point secrets_dir at the mount and type the field as SecretStr; pydantic-settings reads and masks it. The mechanism is a filename convention: for each field, pydantic-settings looks for a file whose name matches the field name inside secrets_dir, reads its contents, strips the trailing newline, and feeds the value through the field’s type — so db_password: SecretStr reads /run/secrets/db_password, masks it, and validates it, all from one declaration. The field name is the contract between your model and the mounted file, which is why the Compose secrets: block below names its secrets db_password and api_key to match.
# config.py
from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
secrets_dir="/run/secrets", # Docker/Swarm mounts secrets here
extra="forbid",
)
db_password: SecretStr # read from /run/secrets/db_password, masked
api_key: SecretStr # read from /run/secrets/api_key
settings = Settings()
# compose.yaml
services:
app:
build: .
secrets:
- db_password
- api_key
secrets:
db_password:
file: ./secrets/db_password.txt # or external: true for Swarm-managed
api_key:
file: ./secrets/api_key.txt
Gotchas & version-specific behaviour
- pydantic-settings strips the trailing newline that Docker secret files often carry — a manual
open().read()would not. - Field names must match the secret file names (respecting
case_sensitive);db_passwordreads/run/secrets/db_password. secrets_diris a lower-priority source than environment variables, so an explicit env var of the same name still wins.- The secrets mount is a tmpfs — it never lands in the image and is gone when the container stops.
- Combine with a manager: in Swarm,
external: truesecrets can be rotated without rebuilding the image.
The precedence point is more useful than it first appears. Because secrets_dir sits below environment variables in the source order, you can use a mounted file as the production credential while still overriding it with an environment variable in development or in a test — the env var wins when present, the file supplies the value otherwise. That means the same model works unchanged across a laptop (where you might export a throwaway DB_PASSWORD) and production (where the real secret is mounted at /run/secrets/db_password), with no code branching on environment. It also means an accidental environment variable of the same name will silently shadow the mounted secret, which is worth knowing when a production value looks wrong: check whether something is exporting the variable and overriding the file you expected to win.
Two facts make Docker secrets genuinely portable. First, Kubernetes projects Secret volumes as files in exactly the same shape — one file per key under a mount path — so pointing secrets_dir at that mount reads Kubernetes secrets with the identical model, no Docker required. Second, because the mount is a tmpfs the secret never touches disk and never enters the image, so the same image is safe to push to a shared registry; the credential is supplied at run time by the platform, not built into the artifact you distribute. Together these mean the secrets_dir pattern is not Docker-specific plumbing but a general “read secrets from a directory of files” mechanism that the major orchestrators all feed.
Production parity checklist
- Mount credentials as Docker/Swarm secrets, not environment variables.
- Type every secret field as
SecretStrand setextra="forbid". - Keep the local
./secrets/*.txtfiles gitignored; ship a.examplealongside. - Validate the model at startup so a missing secret fails the container immediately.
- For managed rotation, use Swarm external secrets or a secret manager.
The gitignore item is the one that protects you locally, where Docker’s tmpfs guarantees do not apply. In development you supply the secret files from a ./secrets/ directory on your real disk, and those files contain live-shaped credentials, so committing them is the same mistake as committing a .env full of passwords. Gitignore the directory, commit a db_password.txt.example with a placeholder so teammates know which files to create, and the repository stays clean while the local workflow stays convenient. Pair this with the startup-validation item — a SecretStr field with no default is required, so a missing mounted secret fails construction immediately, and the container dies on boot rather than running half-configured and failing at the first database call.
Rotation is where the file-based model shines over a baked-in one. Because the secret is mounted rather than embedded, rotating it is a matter of updating the mounted file (a new Swarm secret version, a new Kubernetes Secret, a fresh value from a manager) and cycling the container, with no image rebuild. That decoupling — image is stable, secret is injected — is the same property that makes runtime-fetched secrets rotatable, and it is why the Docker-secrets pattern composes cleanly with the managed stores in the secrets management section: the file mount can itself be populated by an agent that pulls from Vault or a cloud secret store, giving you file-based reads at the app and managed rotation behind them.
Frequently asked questions
How does pydantic-settings read Docker secrets?
Set secrets_dir in SettingsConfigDict pointing at /run/secrets. pydantic-settings reads each field from a same-named file in that directory, so a secret mounted at /run/secrets/db_password populates the db_password field. It reads the file’s contents, strips the trailing newline, and passes the value through the field’s type and validators exactly as it would an environment variable — so a SecretStr field is masked and a field with a @field_validator is checked, all from the mounted file. The field name is the only link you have to maintain: name the mounted secret to match the field, respecting case_sensitive.
Why are Docker secrets safer than environment variables for credentials?
Environment variables are visible via docker inspect and easy to leak into logs. Docker secrets are mounted as in-memory tmpfs files readable only by the container, and never appear in the image or docker inspect output. The exposure gap is wide: an env var is also readable through /proc/<pid>/environ, inherited by every child process the app spawns, stored in the orchestrator’s API object, and — if set with ENV in a Dockerfile — permanently baked into an image layer that travels to every registry. A tmpfs-mounted file has none of those surfaces; it lives only in the running container’s memory-backed filesystem and disappears when the container stops.
What precedence do file secrets have in pydantic-settings?
By default init arguments win, then environment variables, then the .env file, then the secrets_dir files. So a file secret is a lower-priority source than an explicit environment variable of the same name. This is usually what you want — it lets a developer override a mounted secret with an env var locally without touching production behaviour — but it also means a stray environment variable will silently shadow the mounted file, so if a mounted secret appears to be ignored, check whether something is exporting a variable of the same name.
Key takeaways
Mount credentials as Docker secrets and read them with secrets_dir into SecretStr fields; the value stays on a tmpfs, out of docker inspect, and masked in every log line. The pattern is a single secrets_dir line plus a SecretStr annotation per credential, and in return you get the newline stripped, the value masked, the presence validated at startup, and the credential kept off disk and out of the image. Compared to hand-reading files or passing environment variables, it is both safer and less code.
The larger lesson is that pydantic-settings treats a directory of secret files as just one more source in its precedence chain, which is what makes this pattern portable rather than Docker-specific. The same model reads Kubernetes projected secrets, honours an environment-variable override in development, and can sit in front of a managed secret store that populates the mount. You write the model once — fields typed, secrets as SecretStr, extra="forbid" — and the platform decides where the bytes come from. That is the same separation of concerns the rest of this section is built on: the model owns the shape and safety of configuration, and the environment owns its delivery.