Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Interpolation and environment files in Compose

By updated

Improve this page

Logo Docker

Your compose.yaml works, but it contains values that change from one machine to another: a port, a database name, a demo password. Writing them in the file forces you to edit it at every change, and to version them along with it. Compose can fetch them from elsewhere, from two clearly distinct places that this lesson teaches you not to mix up.

This lesson is for a beginner who can write a compose.yaml and read docker compose config. You replace hard-coded values with variables, you store those values in a .env file, then you follow a value all the way into a container. At each step, docker compose config shows what Compose understood before anything starts.

What you will learn

  • Write a variable value with ${VAR}, give it a default value with ${VAR:-default} or make it required with ${VAR:?message}.
  • Store values in a .env file or in a file given with --env-file, and predict which source wins when the shell, --env-file and .env define the same variable.
  • Tell the .env file, which fills in the compose.yaml, apart from env_file: and environment:, which fill in the container.
  • Check the result with docker compose config, then with printenv in the container.
  • Write a $ meant for the container with $$.
  • Name what does not belong in a .env that goes into Git.

Prerequisites

You need the previous lesson, Describe an application with Compose, and what it assumes: a Linux host with Docker Engine 29.9.2 and the Compose 5.6.1 plugin, installed as shown in the lesson on installing Docker. You know how to write a compose.yaml, start it with docker compose up -d, read docker compose config and name a project.

You reuse the same two images, pinned by their SHA-256 digest, in a new ~/vitrine folder. The values in the lesson (passwords included) are demo values: do not reuse any of them on a real project.

How do you put a variable value in a compose.yaml?

You replace the value with ${NAME}. Before even reading the file, Compose replaces each ${NAME} with the value of the variable NAME: this is interpolation. The text ${NAME} no longer exists when Compose decides what to run.

Create the folder and a first file with a single service. The port published on the machine is no longer written in the file.

Terminal window
mkdir ~/vitrine && cd ~/vitrine
services:
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
ports:
- "127.0.0.1:${PORT_SITE}:80"

A variable has a name made of letters, digits and underscores, written in capitals by habit. Here, it still needs a value. The most direct way is to put it in front of the command, which makes it visible for that single command only.

Terminal window
PORT_SITE=18420 docker compose config
name: vitrine
services:
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
networks:
default: null
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 80
published: "18420"
protocol: tcp
networks:
default:
name: vitrine_default

Look at the published line: it contains 18420, the value you set, and no trace of ${PORT_SITE} is left. This is what Compose would run. This command creates nothing, so you can replay it as often as needed.

What happens when a variable is not defined?

Compose does not stop: it prints a warning and treats the variable as an empty string. Run the same command without giving a value.

Terminal window
docker compose config
WARN[0000] The "PORT_SITE" variable is not set. Defaulting to a blank string.

The warning names the missing variable, but the command still succeeds (exit code 0). In the configuration it then prints, the published line has disappeared: ports: boils down to 127.0.0.1::80, which no longer fixes any port on the machine side. At startup, Docker picks one itself, an ephemeral port such as 32768 that docker compose port site 80 prints. This is not what you meant to write, and a warning is easily lost in a terminal. Two notations avoid the problem: give a default value, or refuse to continue.

${NAME:-default} uses default when the variable is unset or empty. ${NAME:?message} stops everything with your message when it is unset or empty. The table summarizes these two forms and their variants without a colon, which only handle the "unset" case.

NotationVariable unsetVariable empty (NAME=)Variable defined
${NAME}empty string, with a warningempty stringits value
${NAME:-default}defaultdefaultits value
${NAME-default}defaultempty stringits value
${NAME:?message}error with the messageerror with the messageits value
${NAME?message}error with the messageempty stringits value

Remember mostly the two forms with a colon: the default value for what may be missing, the error message for what the application cannot make sense without. Edit the file to use both.

services:
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
ports:
- "127.0.0.1:${PORT_SITE:-18420}:80"
environment:
SITE_TITLE: ${SITE_TITLE:?SITE_TITLE est obligatoire}

The port now has a default value, and the site title is required. With nothing defined, docker compose config stops on an error instead of going on with an empty value.

Terminal window
docker compose config
error while interpolating services.site.environment.SITE_TITLE: required variable SITE_TITLE is missing a value: SITE_TITLE est obligatoire

The message names the missing value, then repeats your text. Supply the value and watch the port take its default value.

Terminal window
SITE_TITLE=Vitrine docker compose config | grep -E 'SITE_TITLE|published'
SITE_TITLE: Vitrine
published: "18420"

The title comes from the shell, the port from the default value.

How do you store values in a .env file?

You write them in a file named .env, placed in the project folder, next to the compose.yaml. Compose reads it by itself on every command and uses it to fill in the ${...}. You have nothing to declare in the compose.yaml.

Each line has the form NAME=value. A line that starts with # is a comment. Quotes are used when the value contains spaces or an apostrophe.

Terminal window
cat > .env <<'EOF'
PORT_SITE=18430
SITE_TITLE="Vitrine d'été"
EOF

The name starts with a dot: only ls -a shows it. The double quotes around SITE_TITLE let you write the apostrophe without protecting it. Read the result again.

Terminal window
docker compose config | grep -E 'SITE_TITLE|published'
SITE_TITLE: Vitrine d'été
published: "18430"

Both values come from the .env, and the port from the .env (18430) has taken the place of the default one (18420). The .env file only serves to fill in the compose.yaml. It gives no variable directly to the containers: that is the topic of the section "How do you give variables to the container itself?".

Quotes also change the meaning of the $ sign in a value. Between single quotes, the text is kept as is. Without quotes or between double quotes, Compose replaces $NAME with the matching variable, taken from the shell or from higher up in the file. To write a literal dollar sign outside single quotes, double it ($$) or, between double quotes, precede it with a backslash (\$).

.env lineValue received
NAME='a$HOME b'a$HOME b
NAME="a$HOME b"a, the value of HOME, then b
NAME=pw$$xpw$x
NAME="p\$x"p$x

Do not be surprised to read $$ in the output of docker compose config for values that contain a literal dollar sign: the command doubles every dollar sign so that its output can be read again by Compose. The value a container really receives is the one in the right-hand column.

Which value source wins?

When the same variable is defined in several places, Compose keeps the highest-priority source. To fill in a ${...}, the order is the following, from strongest to weakest.

PrioritySourceExample
1The shell variablePORT_SITE=18440 docker compose config
2The file given with --env-filedocker compose --env-file recette.env config
3The project's .env file, read when no --env-file is given (or when you pass it yourself)the file created above
4The default value of ${NAME:-default}${PORT_SITE:-18420}

Check the first two rows of the table. A shell variable against the .env:

Terminal window
PORT_SITE=18440 docker compose config | grep published
published: "18440"

The .env says 18430, the shell says 18440, and the shell wins. Now create a second values file, recette.env, that only defines the port, then give it with --env-file.

Terminal window
echo 'PORT_SITE=18450' > recette.env
docker compose --env-file recette.env config
error while interpolating services.site.environment.SITE_TITLE: required variable SITE_TITLE is missing a value: SITE_TITLE est obligatoire

The error is instructive. SITE_TITLE is defined in .env, and Compose does not find it: when you give a single --env-file, the project's .env is no longer read. The given file replaces the .env, it does not complete it. There is a way to keep both, presented further down in this section. Add SITE_TITLE to recette.env so that the command succeeds, then combine the three sources.

Terminal window
echo 'SITE_TITLE=Recette' >> recette.env
PORT_SITE=18460 docker compose --env-file recette.env config | grep -E 'SITE_TITLE|published'
SITE_TITLE: Recette
published: "18460"

The title comes from recette.env, because the shell does not define it. The port comes from the shell, because it wins over recette.env. Each variable is resolved separately: the winning source can differ from one variable to another.

To keep the .env and override only a few values, give both files, in order: docker compose --env-file .env --env-file recette.env config. Both are read, and the last one wins for a variable defined in both. A variable present only in .env stays available.

To see at a glance the values Compose kept, docker compose config --environment lists them in the form NAME=value. The list also contains your whole shell environment: filter it, for example with grep -E 'PORT_SITE|SITE_TITLE'.

The shell wins, even when you have forgotten about it. A variable exported earlier in your session (export PORT_SITE=…) wins over the .env with nothing to show it. If a value from the .env seems ignored, run env | grep NAME before looking elsewhere.

How do you give variables to the container itself?

You give them with environment: and env_file:, which have nothing to do with the .env file. These two fields describe the variables the program inside the container will receive, whereas .env and --env-file only fill in the ${...} of the compose.yaml. The diagram puts the two circuits side by side.

In the left-hand column, Compose looks for the value of each ${...} in the shell, then in --env-file (otherwise in .env), then in the default value: the first source that defines the variable wins. In the right-hand column, the container's program receives its variables from environment:, then from env_file:, then from the image's ENV. No arrow links the two columns: the .env only feeds the left-hand one.

Two columns with no link between them. The Interpolation column lines up, from top to bottom, the shell variable, then the file given with --env-file or otherwise the .env file, then the default value written in the compose.yaml, each one used only if the previous one does not define the variable, and ends at the value written in the compose.yaml. The Container column lines up environment:, then env_file:, then the image's ENV, with the same rule, and ends at the variables read by the container's program, without the .env file.Interpolationotherwiseotherwise① shell variable② --env-file file,otherwise the .env file③ default value${VAR:-default}value written intocompose.yamlContainerotherwiseotherwise① environment:② env_file:③ image ENVvariables read by theprogram (without the .env)
Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The .env reaches the container only through a detour: an environment: line that contains a ${...}, like SITE_TITLE above. The container then receives the value Compose found while interpolating, not the file itself.

Add a base service that receives its password from a file, and whose database name comes from a variable. The base.env file deliberately contains an old database name.

Terminal window
cat > base.env <<'EOF'
POSTGRES_PASSWORD=demo-seulement
POSTGRES_DB=ancienne_base
EOF
services:
site:
image: nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949
ports:
- "127.0.0.1:${PORT_SITE:-18420}:80"
environment:
SITE_TITLE: ${SITE_TITLE:?SITE_TITLE est obligatoire}
base:
image: postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
env_file:
- base.env
environment:
POSTGRES_DB: ${DB_NAME:-vitrine}

The base service receives all of base.env through env_file:, then environment: sets POSTGRES_DB to the value of DB_NAME, or to vitrine if it is missing. The database name therefore appears twice, with two different values. Start, then ask the container what it received.

Terminal window
docker compose up -d
docker compose exec base printenv POSTGRES_DB
[+] up 3/3
✔ Network vitrine_default Created 0.0s
✔ Container vitrine-site-1 Started 0.3s
✔ Container vitrine-base-1 Started 0.4s
vitrine

The last line is the answer from printenv: the container received vitrine, not ancienne_base. environment: wins over env_file: when both define the same variable.

The image's variables come last. They are set by the Dockerfile's ENV instruction and only apply if neither environment: nor env_file: defines them. The postgres image sets PG_MAJOR to 18, for example. An env_file: that sets it to 99 makes the container read 99, and an environment: that sets it to 55 makes it read 55.

Now check that the .env does not give its variables to the container. The title is received, because environment: declares it. The port, on the other hand, is declared in no container field: it only appears in the .env and in an interpolation.

Terminal window
docker compose exec site printenv SITE_TITLE
docker compose exec site printenv PORT_SITE; echo "code=$?"
Vitrine d'été
code=1

printenv prints Vitrine d'été for the title. For PORT_SITE, it prints nothing and returns code 1, which means "variable not set". The port was used to build the ports: line, then it disappeared: a ${...} is replaced before startup, and the variable does not travel to the container.

docker compose config tells you what Compose is going to do. printenv tells you what the program received. When an application misbehaves, ask both questions.

To pass a variable from your shell without writing it, list its name alone in environment: (a list entry, - NAME, without =): Compose copies the value from the shell. When the shell does not define the variable, nothing is passed: docker compose config shows it as null, and printenv NAME returns code 1 in the container, as for an unset variable. To get a variable that is present but empty, write - NAME=.

Beware of mixing: an environment: block is written as a list or as a dictionary, never both. As soon as you add a - NAME line, rewrite the whole block as a list (- NAME=value) instead of NAME: value. A mixed block is rejected as invalid YAML.

After changing a value, run docker compose up -d again. Compose recreates only the service whose configuration changed. A container that is already running never rereads its variables: without recreation, it keeps the old value. So docker compose restart is not enough: it restarts the same container, with the same variables.

The change can come from the shell (SITE_TITLE=Autre docker compose up -d), from the .env (another port) or from the contents of an env_file:. A line added to base.env recreates base and leaves site in place. Going back to the old value then recreates the service a second time, and an up -d with no change at all prints Running for each container.

How do you write a dollar sign meant for the container?

Double it: $$. A single $ is always taken by Compose as the start of a variable to replace, even in the middle of a command meant for the container. For Compose to let the dollar sign through, you must write $$: the container then receives a single $.

Make site write a small file that contains its own host name. The HOSTNAME variable exists only in the container: Docker defines it at startup. The command: field replaces the image's startup command: here, a shell writes the file, then runs nginx in the foreground. Add these lines to the site service, next to environment:.

command:
- sh
- -c
- echo "machine=$$HOSTNAME" > /usr/share/nginx/html/identite.txt && exec nginx -g 'daemon off;'

Apply the change, then read the file through the published port and compare it with the name the container gives itself.

Terminal window
docker compose up -d
curl -sS http://127.0.0.1:18430/identite.txt
docker compose exec site hostname
machine=3110cee8118a
3110cee8118a

The published port assumes PORT_SITE=18430, read from the .env. If curl reports a refused connection, nginx is not listening yet: wait a second, then run the command again. Both lines must carry the same identifier, which changes each time the container is recreated. The container's shell expanded $HOSTNAME at startup, because Compose left the $ untouched.

With a single $, Compose would have looked for a HOSTNAME variable in your shell or in the .env, and would have replaced it before startup. If HOSTNAME is defined nowhere, Compose prints the usual warning and the file contains machine= with nothing after it. With HOSTNAME=hote-vm docker compose up -d, it contains machine=hote-vm. In both cases, it is not the container's name.

What should never go in a versioned .env?

Nothing that is secret, and nothing that depends on one particular machine. A .env pushed to Git is copied to every workstation, kept in the history and visible to everyone with access to the repository. A password stays in the history, even if you remove it in the next commit, as the lesson on Docker secrets (in French) points out.

A versioned .env can contain settings shared by the whole team and harmless, such as an image tag or the project name. Passwords, tokens, keys and values specific to one workstation (a local port, a personal path) stay out of the repository. In the lesson, base.env holds a password: it would not go into Git.

The usual practice is to ignore the real files and to version a template with no sensitive value. Write the names of the files to ignore in .gitignore, and keep an example next to them, for instance a base.env.example file whose values are placeholders to replace.

.env
base.env
recette.env

These rules only protect a file that Git does not track yet. If you have already added a .env to the index, .gitignore does not remove it from there, as the lesson on the .gitignore file (in French) explains.

Two other places show your values in clear text: the output of docker compose config, which prints everything that was interpolated, and docker inspect, which lists a container's variables. Do not paste their output into a ticket without rereading it. For the passwords of a real application, the right answer is elsewhere: Compose secrets, covered in the module "Make a stack reliable".

What to do when a value is not the expected one?

Start with docker compose config, which shows the value kept, then with printenv in the container, which shows the value received. The table links the most frequent symptoms to their cause.

What you seeProbable causeWhat you do
The "NAME" variable is not set. Defaulting to a blank string.The variable exists neither in the shell nor in the file readDefine it, or write ${NAME:-default}
required variable NAME is missing a valueThe variable is required (:?) and unset or emptySupply it through the shell, --env-file or .env
A value from the .env is ignoredA shell variable masks it, or --env-file replaced the .envenv | grep NAME, then reread the command you ran
The container does not see a variable from the .envThe .env only fills in the compose.yamlAdd the variable to environment: or to an env_file:
The container keeps the old valueIt was not recreated, or only restarted by restartRun docker compose up -d again
A $ in your command disappearsCompose took it for a variableDouble it: $$

How do you clean everything up after the demo?

The demo leaves containers, a network, an anonymous volume created by the postgres image, the ~/vitrine folder and two images. The -v option of docker compose down removes this anonymous volume along with the containers and the network. Then remove the images, then the folder.

Terminal window
cd ~/vitrine
docker compose down -v
docker rmi nginx:1.30.6-alpine@sha256:0e52bd054126cdc040b1276c0af347aeb02a41b9cfe305d6094767ac30e97949 \
postgres:18.6-alpine3.24@sha256:10a5c69a83035ab5239f5620d708520e8b28f5aa1f2b20143e6024532edadfb5
cd ~ && rm -f vitrine/compose.yaml vitrine/.env vitrine/base.env vitrine/recette.env \
vitrine/.gitignore vitrine/base.env.example && rmdir vitrine

After that, docker ps -a, docker volume ls and docker images show nothing left from the lesson. Also check that no variable from the lesson lingers in your session with env | grep -E 'PORT_SITE|SITE_TITLE|DB_NAME': the command must print nothing.

One case is an exception. -v only removes the volume of the containers that exist at that moment. If you ran docker compose down without -v, then a new up -d, the volume of the first set of containers stays behind, orphaned. Spot it with docker volume ls -f dangling=true and remove it with docker volume rm.

Practice

The lab gives you a Compose declaration that mixes up the values used to build the file with those the container receives. You do not modify the values files: you only repair the compose.yaml, so that each value arrives by the right path, and you start a service under an imposed project name.

The check looks at what runs, not at what you typed. It reads the configuration Compose resolves, the container's real variables and the file the container writes at startup, which also grades your use of $$. It then changes a value in the shell, recreates the service, and requires that this value wins over the one in the file; finally it recreates the original deployment. A container tweaked by hand does not survive this recreation: only the file counts.

Key points

  1. ${NAME} is replaced by Compose before startup, and by an empty string if the variable is missing. ${NAME:-default} gives a default value, ${NAME:?message} stops the command with your message.

  2. The .env file in the project folder is read by itself and fills in the ${...}. --env-file gives another file, which replaces the .env instead of completing it. To keep both, pass --env-file .env --env-file recette.env: the last one wins.

  3. For an interpolation, the shell wins over --env-file, which wins over .env (read only without --env-file, or passed in addition), which wins over the default value. Each variable is resolved separately.

  4. The .env gives no variable directly to the container. environment: and env_file: do, and environment: wins over env_file:, which wins over the image's ENV.

  5. docker compose config shows what Compose is going to do, printenv in the container shows what it received. After a change of value, docker compose up -d recreates the service.

  6. A $ meant for the container is written $$.

  7. A versioned .env contains no secret and no workstation-specific setting: the real files are ignored in .gitignore and an example is versioned.

Knowledge check

Check that the essentials of this lesson have stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn