Skip to main content
ANVISoftware Solutions
Lesson 12 of 16Intermediate16 min

Environment Variables

By the end of this lesson

Configure a container at run time instead of baking settings into the image.

An environment variable is a name and a value that the operating system hands to a process when it starts. The process reads them the way it reads any other input. Docker sets them when it creates a container, which makes them the usual way to tell one image how to behave in a particular place.

That is the point of the technique. One image, built once and tested once, moves from development to staging to production unchanged. The database host, the log level and the feature flags arrive from outside it. Build a separate image per environment instead and you have three artefacts that were never tested together, and the one you tested is not the one you shipped.

You have already used this without dwelling on it. POSTGRES_PASSWORD and POSTGRES_DB in the earlier lessons are environment variables the PostgreSQL image reads on first start. Images you did not write are configured the same way as images you did.

Three ways to supply values, and how to check what arrived
Shell
# One flag per variable
docker run -d --name employees-api --network employees-net -p 8080:8080 \
  -e ASPNETCORE_ENVIRONMENT=Staging \
  -e ASPNETCORE_HTTP_PORTS=8080 \
  -e "ConnectionStrings__Employees=Host=db;Port=5432;Database=employees;Username=api_user;Password=local-dev-placeholder" \
  -e Logging__LogLevel__Default=Warning \
  anvi/employees-api:1.4.0

# The same settings collected in a file: one NAME=value per line, no quotes
cat config/api.staging.env
# ASPNETCORE_ENVIRONMENT=Staging
# ASPNETCORE_HTTP_PORTS=8080
# Logging__LogLevel__Default=Warning

docker run -d --name employees-api --network employees-net -p 8080:8080 \
  --env-file config/api.staging.env \
  anvi/employees-api:1.4.0

# Pass a value through from the shell that ran the command
docker run --rm -e ASPNETCORE_ENVIRONMENT alpine:3.20 printenv ASPNETCORE_ENVIRONMENT

# What a container was actually given, including defaults from the image
docker inspect -f '{{json .Config.Env}}' employees-api
docker exec employees-api printenv ASPNETCORE_ENVIRONMENT
  • -e NAME=value sets one variable. Repeat the flag for each one — there is no comma-separated form, and a second -e does not replace the first.
  • The connection string is wrapped in quotes because it contains semicolons, which your shell would otherwise read as command separators. Quote the whole NAME=value, not only the value.
  • --env-file reads NAME=value lines. Docker does not strip quotes inside that file, so a value written with quotes around it keeps them. This surprises people, and the symptom is a password that is wrong by exactly two characters.
  • -e NAME with no equals sign copies whatever the calling shell has for that name. It keeps secrets out of scripts and command history, and it fails quietly when the variable was never set.
  • docker inspect shows the environment the container was created with, including anything ENV put in the image. Read this before concluding the application ignored a setting — usually the setting never arrived.
  • printenv inside the container is the same answer from the other side. Both are worth knowing, because one works on a stopped container and the other proves what the running process can see.

Values can come from five places. Later entries override earlier ones for the same name:

ENV in the Dockerfile
A default baked into the image. Right for something true everywhere the image runs, such as the port it listens on. Wrong for anything that differs per environment.
--env-file, or env_file in Compose
A file of NAME=value lines, kept per environment. Convenient when a service has a dozen settings, and the file is a thing you have to get onto the machine safely.
-e, or environment in Compose
Set on the container directly. Overrides both the image default and the env file, so it is the place to put the one value you are changing today.
The calling shell
-e NAME with no value, or Compose substitution from your shell and its .env file. The value never appears in the file you committed.
A mounted file the application reads
Not an environment variable at all: a file mounted into the container, read by your configuration code. The option to reach for when a value is too long for one line, or too sensitive for the process environment.

ASP.NET Core reads configuration from several sources and merges them into one set of keys. Keys are hierarchical, written with a colon: ConnectionStrings:Employees names the Employees entry inside the ConnectionStrings section of appsettings.json.

A colon is not a usable character in an environment variable name on every platform, so the environment variable provider accepts two underscores in its place. ConnectionStrings__Employees becomes ConnectionStrings:Employees before the rest of the application sees it. Nesting goes as deep as you need, two underscores per level.

By default the environment variables are added after the JSON files, so a variable wins over the same key in appsettings.json. That is what makes the technique work: the file holds development defaults and stays in the repository, and the environment supplies what is different about each place the image runs.

The same settings, written both ways:

 In appsettings.jsonAs an environment variable
Connection stringConnectionStrings, then Employees inside itConnectionStrings__Employees
Default log levelLogging, then LogLevel, then DefaultLogging__LogLevel__Default
Separator between levelsOne object nested inside anotherTwo underscores. One underscore binds nothing
An item in an arrayThe third entry under Cors, then OriginsCors__Origins__2 — array positions are numbered from zero
Letter caseAs written in the fileMatched without regard to case, so CONNECTIONSTRINGS__EMPLOYEES binds too
Which one winsThe committed defaultOverrides the file when both define the same key
compose.yaml — the same configuration, declared rather than typed
YAML
services:
  api:
    image: anvi/employees-api:1.4.0
    env_file:
      - ./config/api.common.env
    environment:
      ASPNETCORE_ENVIRONMENT: Development
      ASPNETCORE_HTTP_PORTS: "8080"
      Logging__LogLevel__Default: Information
      ConnectionStrings__Employees: Host=db;Port=5432;Database=employees;Username=api_user;Password=${POSTGRES_PASSWORD}
    ports:
      - "8080:8080"
  • environment takes a mapping of names to values. A list of NAME=value strings works too; the mapping form is easier to read once the values get long.
  • 8080 is quoted. Environment variables are text by definition, and YAML would otherwise read an unquoted 8080 as a number — quote anything that looks like a number, a version or a boolean.
  • The ${POSTGRES_PASSWORD} placeholder is substituted by Compose before the container is created, from your shell or from a .env file next to the Compose file. The application never sees the placeholder, only the value.
  • A name Compose cannot substitute becomes an empty string with a warning rather than an error. The container then starts with a blank password and fails later, somewhere less obvious.
  • env_file paths are relative to the Compose file. Names set in environment override the same names from env_file, which is the precedence from the list above expressed in one place.
  • The Docker Compose lesson builds this out into the full multi-service file. The configuration shape does not change when it gets there.

Summary

  • Environment variables let one image behave differently in each environment, so the artefact you tested is the artefact you ship
  • Supply them with -e, with --env-file, or with environment and env_file in Compose; the more specific source wins
  • ASP.NET Core maps Section__Key onto nested configuration keys, and environment values override appsettings.json
  • A container's environment is fixed when the container is created, so changing a value means a new container
  • Environment variables are readable by anyone who can inspect the container, so secrets belong in a secret store or a mounted file

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Change behaviour without rebuilding

Start your employees API container with Logging__LogLevel__Default set to Warning, and confirm with docker logs how little it writes.

Remove the container and start a new one from the same image with the level set to Debug. Then read the environment back with docker inspect and with printenv inside the container.

Show solution

The two containers behave differently and the image is identical, which is the whole idea. Nothing was rebuilt, and nothing about the artefact changed between the two runs.

The step worth not skipping is removing the first container. A restart keeps the old environment, so trying to change a variable in place is the most common way to conclude that environment variables do not work.

Reading the value back from both sides is a habit that saves time later. docker inspect tells you what Docker was asked for; printenv tells you what the process received. When those two agree and the application still ignores the setting, the problem is the key name — usually one underscore where there should be two.

Shell
docker run -d --name api-quiet -p 8080:8080 \
  -e Logging__LogLevel__Default=Warning anvi/employees-api:1.4.0
docker logs api-quiet

docker rm -f api-quiet
docker run -d --name api-loud -p 8080:8080 \
  -e Logging__LogLevel__Default=Debug anvi/employees-api:1.4.0
docker logs api-loud

docker inspect -f '{{json .Config.Env}}' api-loud
docker exec api-loud printenv Logging__LogLevel__Default
docker rm -f api-loud

Think about it

Think about it

A team builds three images from the same commit: employees-api:1.4.0-dev, :1.4.0-test and :1.4.0-prod. Each one has its connection string and log level set with ENV in the Dockerfile, so deployment takes no configuration at all.

What has this bought them, and what has it cost?

Show solution

It has bought a simpler deployment step. Nothing has to be supplied at run time, so there is no env file to get onto the machine and no variable anyone can forget.

The cost is that testing no longer means much. Three images are three artefacts, and the one that passed testing is not the one running in production. A build difference — a cached layer, a dependency resolved a minute later, a typo in one of the three files — produces a failure that only appears in the environment nobody tested.

It also puts the production connection string inside an image, where it travels to every registry and every machine that pulls it, and cannot be changed without a rebuild. Rotating a password becomes a release.

The alternative is one image and three sets of configuration held outside it. That moves the risk to supplying configuration correctly, which is a smaller and more visible problem: a missing variable fails at start-up, in one place, with a message about the variable.

Knowledge check

Nothing is recorded and there is no score. The explanation appears either way.

An ASP.NET Core API reads its connection string from ConnectionStrings:Employees. Which environment variable name binds to it?
Why are environment variables an incomplete answer for a production database password?

Saved in this browser only.