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_URLARG 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_URLVite 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 containerSame 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 bundleThe 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-bytesAcceptable 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.