Docker – Environment Variables, Build Args and Secrets

July 18, 20264 min readUpdated 8/21/2026

The point of an image is that the artifact you tested is the artifact you deploy. Which means one image has to run in several places, and the differences between those places have to come from outside it.

Docker gives you two mechanisms, and the difference between them decides what you can change later.

ARG is build time. ENV is run time.

ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

ARG exists only while the image is being built. It is passed with --build-arg, is not present in the finished image, and cannot be changed afterwards.

ENV is baked into the image and set in every container started from it — and can be overridden at run time with -e.

So the question to ask of any setting is: can this change without rebuilding? If yes, it is ENV. If no, it is ARG, and you have one image per value.

Server-side: pass it at run time

The API takes everything from the environment:

environment:
  SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/pizza?useSSL=false&allowPublicKeyRetrieval=true&connectionTimeZone=LOCAL&preserveInstants=false
  SPRING_DATASOURCE_USERNAME: root
  SPRING_DATASOURCE_PASSWORD: ""

Note what is not here: a new application-container.properties. Spring Boot's relaxed binding maps SPRING_DATASOURCE_URL onto the spring.datasource.url property — upper-case, dots and dashes to underscores — and environment variables outrank every properties file. So the committed file keeps working for a developer running the app natively, and the container overrides it without a second copy of the config to keep in sync.

Most frameworks have an equivalent: DATABASE_URL in Rails and Django, process.env in Node. This is the twelve-factor idea, and Docker is why it became standard rather than optional.

Client-side: it is baked in, and you cannot change it later

Here is where people get caught. A single-page application runs in the browser, not in the container — so there is no environment for it to read. Its configuration was compiled into the JavaScript at build time, and no amount of docker run -e will touch it.

Both frontends in this track need to know where the API is, and the two frameworks solve it differently. The contrast is worth seeing.

React: a build arg

ARG VITE_API_BASE_URL=""
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL

Vite substitutes import.meta.env.VITE_* into the bundle during the build, so it reads that variable and the value is compiled in.

build:
  context: ./pizza-react-frontend
  args:
    VITE_API_BASE_URL: ""

Empty, deliberately — which makes the app request /api/... on its own origin, where nginx proxies it to the API container. One line.

Angular: a file and a build configuration

Angular has no import.meta.env and no runtime environment mechanism at all. The CLI's mechanism is fileReplacements: swap one source file for another at build time. So the same idea takes a new file —

export const environment = {
  production: true,
  apiBaseUrl: '',
  stripePublishableKey:
    'pk_test_51U5Wc3BeMrxmFducR7hlZ3YwT770EF2DFj8VPmEmqZ7r2sVasfWDRjWMQBvEqdWOSuIGg6RSd8oIcjQ9RblgJxRq00ThBQPY9F',
};

— plus a build configuration that swaps it in:

"container": {
  "budgets": [
    {
      "type": "initial",
      "maximumWarning": "800kB",
      "maximumError": "1MB"
    },
    {
      "type": "anyComponentStyle",
      "maximumWarning": "4kB",
      "maximumError": "8kB"
    }
  ],
  "outputHashing": "all",
  "fileReplacements": [
    {
      "replace": "src/environments/environment.ts",
      "with": "src/environments/environment.container.ts"
    }
  ]
},

— plus a flag on the build:

RUN npm run build -- --configuration container

Same outcome, three times the ceremony, and no build arg at all. Worth knowing before you plan a per-environment image strategy around one framework or the other.

You can verify the substitution happened rather than hoping:

$ docker compose exec web-angular \
    sh -c 'grep -c "http://localhost:8085" /usr/share/nginx/html/*.js'
# no matches — the absolute URL is not in the bundle

The third option: runtime config

If one image really must serve every environment, do not bake anything. Have the app fetch a small file on startup:

GET /config.json  ->  { "apiBaseUrl": "https://api.example.com" }

and bind-mount or generate that file per deployment. Costs a request before the app can start; buys you one artifact for every environment.

--env-file, and .env

docker run --env-file ./local.env pizza-api:dev

⚠️ Compose has a different mechanism with a confusingly similar name. A .env file next to compose.yaml is read by Compose itself, to interpolate ${VAR} in the compose file — it does not automatically become the container's environment:

STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}

${VAR:-default} is evaluated on the host, before the container exists. So those two lines read the keys from your shell or from .env if they are set, and pass empty strings if not — which is how a real key stays out of a committed file entirely.

For the container to receive a whole file, that is env_file: on the service.

Secrets

An environment variable is not a secret store, and it is worth being specific about why:

docker inspect prints it. Anything that dumps the environment on a crash logs it. It is inherited by every child process. And in a compose file it is in git.

The compose file in this track has one committed:

PIZZA_JWT_SECRET: local-compose-only-not-a-real-secret-at-least-32-bytes

Acceptable only because it signs tokens for a demo on your own machine, and it is there so the stack starts with one command. Anywhere reachable, in increasing order of effort:

A gitignored .env next to the compose file. Keeps it out of git; still visible to docker inspect.

Docker's secrets:, which mounts the value as a file at /run/secrets/<name> rather than putting it in the environment. Not in inspect, not inherited by child processes.

A real secret store — AWS Secrets Manager, Vault — fetched at startup. The only option that gives you rotation and an audit trail.

And for a secret genuinely needed during the build, lesson 9's --mount=type=secret. Never an ARG: docker history prints those.

Next: when it does not work.