Mount Secrets as Files vs Env Vars in Kubernetes

Both delivery mechanisms work, both are one line of manifest, and the choice between them determines whether a rotation can reach a running pod, whether a subprocess inherits your credentials, and whether a crash dump contains them. This page compares them concretely for a Python workload and shows the mounted-file setup that pydantic-settings supports natively, extending the Kubernetes secrets topic.

Problem 1: every subprocess inherits the environment

# ANTI-PATTERN: the credential travels into a process that has no business seeing it
import subprocess
subprocess.run(["pg_dump", "app"])     # inherits DATABASE_PASSWORD from os.environ

An environment variable is inherited by every child process by default. A backup tool, a shell-out to a CLI, a health-check script, a debugging one-liner — each receives the full set of credentials the parent holds, and each can log, dump or transmit them without anybody having decided that was acceptable. The value is also visible in /proc/self/environ to the process owner, so anything running as the same user inside the container can read it without any interaction with your application at all.

Files invert the default. A subprocess inherits nothing unless it is given a path and the permission to read it, so the credential’s reach is exactly the code that opens the file. That is a meaningful reduction in surface for a change that costs a mount stanza.

Inheritance of environment variables versus explicit file access Environment variables are inherited by every subprocess automatically, while a mounted file is read only by code that opens it, so the credential's reach is limited to that code. environment variable app process pg_dumpshellhealthcheck all three inherit it automatically mounted file app process pg_dumpshellhealthcheck none of them see it unless told to
Inheritance is the difference: one mechanism spreads by default, the other does not spread at all.

Problem 2: assuming an environment variable can be rotated

# ANTI-PATTERN: this value is fixed for the life of the container
env:
  - name: API_TOKEN
    valueFrom:
      secretKeyRef: { name: api-credentials, key: api_token }

Environment variables are resolved at container start and cannot change afterwards — there is no mechanism by which Kubernetes could rewrite a running process’s environment. A rotation that updates the Secret therefore has no effect at all until the pod is replaced, which is the surprise that makes a scheduled credential expiry take down every consumer simultaneously.

Mounted files behave differently: the kubelet refreshes them in the background, typically within about a minute. That does not automatically update the value your process is holding — that is a separate problem — but it means the current value is available on disk without a restart, which is the precondition for any live-reload strategy.

Problem 3: default file permissions that are wider than expected

# ANTI-PATTERN: readable by more than the process that needs it
volumes:
  - name: app-secrets
    secret:
      secretName: api-credentials      # no defaultMode: broader than you would choose

Without an explicit mode, mounted secret files are more permissive than most people assume. That matters in three common situations: a container that starts as root and drops privileges leaves files readable by the original owner’s group, a sidecar in the same pod may share the mount, and anyone who can exec into the pod reads them trivially.

defaultMode: 0400 restricts the files to their owner and costs nothing. Combine it with a container that runs as a non-root user, and the file is readable by exactly the process that needs it. This is defence in depth rather than a boundary — anyone with exec rights can still become that user — but it removes the accidental cases, which are the ones that actually occur.

Secure implementation

# deployment.yaml — files for credentials, environment for plain configuration
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 10001
      containers:
        - name: api
          image: registry.example.com/api:2.1.0
          env:
            - name: ENVIRONMENT           # non-secret: visible config is helpful here
              value: production
            - name: LOG_LEVEL
              value: INFO
          volumeMounts:
            - name: app-secrets
              mountPath: /etc/secrets
              readOnly: true
      volumes:
        - name: app-secrets
          secret:
            secretName: api-credentials
            defaultMode: 0400             # owner-read only
# config.py — one model reading both sources, with a local fallback
from pathlib import Path
from pydantic import SecretStr, PostgresDsn, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

SECRETS_DIR = Path("/etc/secrets")


class Settings(BaseSettings):
    model_config = SettingsConfigDict(
        # 1. files supply the credentials; absent locally, so the same image runs anywhere
        secrets_dir=str(SECRETS_DIR) if SECRETS_DIR.is_dir() else None,
        env_file=".env",                 # 2. local convenience only
        extra="forbid",
    )

    environment: str = "local"           # 3. from the process environment
    log_level: str = "INFO"
    database_url: PostgresDsn            # 4. from /etc/secrets/database_url
    api_token: SecretStr                 # 5. from /etc/secrets/api_token

    @field_validator("api_token", mode="before")
    @classmethod
    def _strip_whitespace(cls, value: object) -> object:
        # 6. a trailing newline from however the Secret was created
        return value.strip() if isinstance(value, str) else value


settings = Settings()                    # 7. a missing file fails startup, before traffic

The model reads both sources without knowing which is which — secrets_dir files and environment variables are simply two sources in its precedence order — so the split between them is an operational decision that requires no code change. Moving a value from one to the other is a manifest edit, which is exactly the flexibility you want when a third-party component turns out to insist on an environment variable.

The whitespace validator is not optional in practice. A Secret created with --from-file keeps the file’s trailing newline, and a token with \n appended fails authentication with an error that names nothing useful. Stripping in a mode="before" validator normalises the value before any type validation runs, so the fix applies regardless of which source supplied it.

Comparison of the two delivery mechanisms Environment variables are fixed at container start, inherited by subprocesses and visible in the process environment, while mounted files refresh in place, are not inherited and can be permission-restricted. env var mounted file updates without restart inherited by subprocesses visible in the environment permission-restricted noalwaysyesno yes, on disknevernodefaultMode
Four properties, and the file column wins every one that matters for a credential.

Where environment variables still make sense

None of this makes environment variables bad — it makes them the wrong home for credentials. Non-secret configuration is genuinely better as an environment variable, because it is visible in the manifest, visible in kubectl describe, and immediately obvious to anyone debugging. An environment name, a log level, a feature flag, a public endpoint: putting these in files buys nothing and hides information that helps.

There is also a category of value where the environment is the only practical option: a third-party component that reads a specific variable name and offers no file-based alternative. Injecting that one variable explicitly, rather than switching the whole workload to environment delivery, keeps the exception narrow and documented. Where the component supports a file-path variable — many do, with a _FILE suffix convention — use it, since that gets you the file’s properties with the environment’s interface.

A related convenience worth adopting is to keep the environment side small enough to read at a glance. A container whose env block is four lines tells a reviewer what varies between environments in about two seconds; one with thirty entries tells them nothing without a careful comparison. The mixed setup is therefore the normal one, and the split is worth writing down somewhere visible: credentials in /etc/secrets, everything else in env. A reviewer can then check any manifest against one rule, and a new service starts from the right shape rather than from whatever the last example happened to do.

Migrating an existing workload from variables to files is undramatic, which is worth knowing because it is often postponed as though it were risky. Add the volume and mount alongside the existing env entries, deploy, and confirm the model still starts — at this point both sources are present and the environment still wins or loses according to your precedence, so nothing has changed behaviourally. Then remove the env entries in a second deploy. Two small changes, each independently reversible, and the only thing to verify in between is that the file names match the field names the model expects.

Gotchas and version-specific behaviour

  • Environment variables are fixed at container start; there is no mechanism to update them in a running process.
  • Mounted files refresh within roughly a minute, except with subPath mounts or immutable Secrets, both of which opt out.
  • defaultMode: 0400 is worth setting even in a single-process container, because sidecars and privilege-dropping change who can read.
  • envFrom injects every key of a Secret, so a key added elsewhere becomes a variable your model may reject under extra="forbid".
  • secrets_dir maps file names to field names directly, so choosing file names that match your fields removes any mapping code.
  • A trailing newline from --from-file is the single most common cause of “the value looks right but authentication fails”.
  • Each container in a pod needs its own volumeMounts entry; an init container that runs migrations does not inherit the app container’s mounts.

Production parity checklist

  • Credentials arrive as mounted files with defaultMode: 0400 and readOnly: true.
  • Non-secret configuration arrives as environment variables and is visible in the manifest.
  • The container runs as a non-root user, so the restrictive file mode is meaningful.
  • A mode="before" validator strips whitespace on every file-sourced field.
  • The settings model detects the mount directory so the same image runs locally without it.
  • Every container that needs the values — including init containers and sidecars — declares its own mount, since volume mounts are per container rather than per pod.
The mixed layout most workloads should use Non-secret configuration is delivered as environment variables for visibility, credentials are mounted as read-only files, and one settings model reads both without knowing the difference. env: ENVIRONMENT, LOG_LEVEL visible, non-secret /etc/secrets/*, mode 0400 credentials only one settings model two sources, one schema validated once at startup
The split is operational; the model sees two sources and one schema.

Frequently asked questions

Which is more secure, a file or an environment variable?

A file, by a meaningful margin. Environment variables are inherited by every subprocess, readable through /proc for the process owner, and frequently included in crash dumps, diagnostic endpoints and error-tracker context; a file is read only by code that opens it. Neither protects against an attacker who can execute code in the container, so this is about reducing accidental spread rather than establishing a boundary — but accidental spread is what actually causes incidents, and files remove most of it for the cost of a mount stanza.

Can I use both in the same pod?

Yes, and it is often the right answer. Keep non-secret configuration in environment variables where it is easy to see in a manifest or a describe output, and mount credentials as files where they stay out of the environment. pydantic-settings reads both in one model, so the split costs nothing in code and can be adjusted later without touching the application. Write the rule down — credentials as files, everything else as variables — so every new service starts from the same shape.

Why does my file-sourced token have a trailing newline?

Because whatever created the Secret preserved one — a file piped through kubectl create secret --from-file, or an editor that adds a final newline as most do. The value looks correct in every terminal and fails authentication by one character. Strip whitespace in a mode="before" validator so the value is clean regardless of how it was written, and the entire class of “the credential is right but the API rejects it” disappears.

Does defaultMode really matter if the container runs as one user?

Yes. Containers that start as root and drop privileges leave files readable by the original owner and group, sidecars in the same pod may share the mount, and a debugging shell runs with whatever identity the pod grants. A restrictive mode is doing real work in all three cases. It is not a boundary against someone with exec rights, but it removes the accidental readers, which is the population that matters.

How does pydantic-settings map files to fields?

With secrets_dir, each file in the directory becomes a candidate value keyed by its name, so a file called database_url populates the database_url field with the file’s contents. That direct mapping is why choosing your Secret’s keys to match your model’s field names is worth doing: no translation layer, no mapping dictionary, and the manifest becomes a readable list of exactly what the workload consumes.

Key takeaways

For credentials, mounted files win on every property that matters: they are not inherited by subprocesses, they do not appear in the process environment or in the diagnostic surfaces that read it, they can be permission-restricted, and they refresh in place so a rotation is at least available without a restart. Environment variables are fixed at container start, which is the fact that turns a scheduled credential expiry into a simultaneous outage across every consumer.

The practical setup is mixed and deliberate: non-secret configuration as environment variables where visibility helps, credentials as read-only files with defaultMode: 0400 under a non-root user, and one settings model reading both. Add a whitespace-stripping validator on file-sourced fields, detect the mount directory so the same image still runs on a laptop, and make sure every container that needs the values declares the mount — including the init container everybody forgets. The wider delivery picture is on the Kubernetes secrets topic page.