Environment Variables
Development following the twelve-factor app use the environment variables to configure their application.
Often there are many environment variables and having them in a .env file
becomes handy. Docker and Compose do use
environment variables file to pass the variables to the
containers.
Naming and envfile structure
Section titled “Naming and envfile structure”Environment variables for an application can be mixed with other environment
variables coming from other applications/dependencies. One way to distinguish
from one to another is to prepend with something like ENV_. This makes it easy
when you want to only see them: env | grep ENV_. One example is GitHub Actions
as it uses
environment variables starting with GITHUB_.
Also, environment variables can be used at different stages of software development: build, test, deploy, and run time. Naming them accordingly may be a good idea.
Naming variables and structuring envfile are a personal taste. Currently I prefer structuring it by ordering variables and descriptions:
# ENV_MY_VAR_1 is description 1# ENV_MY_VAR_2 is description 2ENV_MY_VAR_1ENV_MY_VAR_2Envfile and expectations
Section titled “Envfile and expectations”Given the file .env:
# Make sure these env vars are not set in the systemENV_AENV_B=ENV_C=env_cAnd the file compose.yml:
services: alpine: image: alpine env_file: .envThe expected results are:
docker run --rm --env-file=.env alpine env#ENV_B=#ENV_C=env_c# ENV_A is not set and ENV_B is set to empty
docker compose run --rm alpine env#ENV_B=#ENV_C=env_c# Same as DockerTemplate and example envfiles
Section titled “Template and example envfiles”env.template and env.example files provide some help when managing
environment variables in a project.
env.template
Section titled “env.template”env.template contains names (key-only) of all environment variables the
application and pipeline use. No values are set here. # description can be
used to describe an environment variable. env.template is mainly used as a
template to .env in a CI/CD pipeline.
ENV_VAR_AENV_VAR_Benv.example
Section titled “env.example”env.example defines values so that it can be used straight away with Make like
$ make envfile test ENVFILE=env.example. It also gives an example of values
that are being used in the project which is very useful for the developers.
ENV_VAR_A=aENV_VAR_B=b- Simple
- Understanding the concept is pretty straight forward
- Does not require any script
- Application agnostic
- This pattern can be used for any environment variable of any kind of application
- Descriptive and explicit
env.templatetells what environment variables are used by the projectenv.exampleshows what value those environment variables can have- Environment variables needs to explicitly be added
- Flexible
- The way the environment variables are set is up to you. They can be included
in the
.envfile when developing locally or exported in a CD/CI host
- The way the environment variables are set is up to you. They can be included
in the
- Environment variable management is not centralized
- Adding, modifying, or deleting environment variables may impact multiple
files such as
- env.template
- env.example
- makefile
- compose.yml
- application source code
- pipeline-as-code file
- Adding, modifying, or deleting environment variables may impact multiple
files such as
- Error prone
- It is easy to forget to add a new environment variable to the
env.template/env.examplefiles
- It is easy to forget to add a new environment variable to the
CI/CD pipeline
Section titled “CI/CD pipeline”Given all environment variables are set in your CI/CD pipeline, creating a
.env file based on env.template allows values of those environment variables
to be passed to the Docker container environments.
Day-to-day development
Section titled “Day-to-day development”In a day-to-day development process, you could create a file named .env.dev
with the config of your dev environment and copy the contents of it into .env
so that you can manually deploy/delete/etc your app for testing. This allows you
to not accidentally lose the values if the .env file is replaced. There are
few ways to copy the contents of your file to .env:
- manually
make envfile ENVFILE=env.example(refer to section Create envfile)
Create envfile
Section titled “Create envfile”This section shows some ways to create .env file with Make and Docker/Compose.
With Make and Compose
Section titled “With Make and Compose”Given the file env.template:
ENV_MY_VARAnd the file env.example:
ENV_MY_VAR=MY_VALUEAnd the file compose.yml:
version: "3.8"services: alpine: image: alpine env_file: ${ENVFILE:-.env} volumes: - type: bind source: "." target: /opt/app working_dir: /opt/appExplicit
Section titled “Explicit”Targets requiring .env file will fail if the file does not exist. The .env
file can be created with envfile target.
COMPOSE_RUN_ALPINE = docker compose run alpineENVFILE ?= env.template
envfile: ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) cp $(ENVFILE) .env
targetA: $(COMPOSE_RUN_ALPINE) cat .env
targetB: .env $(COMPOSE_RUN_ALPINE) cat .env
clean: ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) rm -f .env# Compose will return an error if .env does not exist because of `env_file: ${ENVFILE:-.env}`make targetA# Make will return an error if .env does not existmake targetB# Overwrite .env based on env.template. The reason why `make envfile` it call Compose with `ENVFILE=$(ENVFILE)`make envfile# Overwrite .env with env.examplemake envfile ENVFILE=env.example# Overwrite .env with env.example before running targetAmake envfile targetA ENVFILE=env.exampleSemi-Implicit
Section titled “Semi-Implicit”Targets requiring .env file will get it created if it does not exist. The
.env file can be overwritten by calling make envfile ENVFILE=.env.example.
COMPOSE_RUN_ALPINE = docker compose run alpineENVFILE ?= env.template
.env: $(MAKE) envfile
envfile: ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) cp $(ENVFILE) .env
target: .env $(COMPOSE_RUN_ALPINE) cat .env
clean: .env $(COMPOSE_RUN_ALPINE) rm .env# Create .env based on env.template if .env does not existmake target# Create .env based on env.template if .env does not existmake .env# Create .env based on $(ENVFILE) if .env does not existmake .env ENVFILE=env.example# Overwrite .env based on env.templatemake envfile# Overwrite .env with a specific filemake envfile ENVFILE=env.example# Execute a target with a specific .env filemake envfile target ENVFILE=env.exampleImplicit
Section titled “Implicit”Targets requiring .env file will get it created if it does not exist. The
.env file can be overwritten by setting ENVFILE environment variable.
COMPOSE_RUN_ALPINE = docker compose run alpineifdef ENVFILE ENVFILE_TARGET=envfileelse ENVFILE_TARGET=.envendif
.env: $(MAKE) envfile ENVFILE=env.template
envfile: ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) cp $(ENVFILE) .env
target: $(ENVFILE_TARGET) $(COMPOSE_RUN_ALPINE) cat .env
clean: $(ENVFILE_TARGET) $(COMPOSE_RUN_ALPINE) rm .env# Create .env based on env.template if .env does not existmake target# Create .env based on env.template if .env does not existmake .env# Create .env based on env.example if .env does not existmake .env ENVFILE=env.example# Overwrite .env with env.examplemake envfile ENVFILE=env.example# Execute a target with env.examplemake envfile target ENVFILE=env.example# Or (no need to specify envfile)make target ENVFILE=env.exampleWith Make and Docker
Section titled “With Make and Docker”Everything covered in section
With Make and Compose can be applied here
except Docker won’t use compose.yml. Here’s an example with the explicit
method:
MAKEFILE_DIR = $(dir $(abspath $(firstword $(MAKEFILE_LIST))))DOCKER_RUN_ALPINE = docker run --rm \ -v $(MAKEFILE_DIR):/opt/app \ -w /opt/app \ alpineDOCKER_RUN_ALPINE_WITH_ENVFILE = docker run --rm \ -v $(MAKEFILE_DIR):/opt/app \ -w /opt/app \ --env-file .env \ alpineENVFILE ?= env.template
envfile: $(DOCKER_RUN_ALPINE) cp $(ENVFILE) .env
targetA: $(DOCKER_RUN_ALPINE_WITH_ENVFILE) cat .env
targetB: .env $(DOCKER_RUN_ALPINE_WITH_ENVFILE) cat .env
clean: $(DOCKER_RUN_ALPINE) rm .env# Docker will return an error if .env does not existmake targetA# Make will return an error if .env does not existmake targetB# Overwrite .env based on env.template. The reason why it does not fail is because it uses `DOCKER_RUN_ALPINE`make envfile# Overwrite .env with env.examplemake envfile ENVFILE=env.example# Overwrite .env with env.example before running targetAmake envfile targetA ENVFILE=env.exampleOverwriting .env or not
Section titled “Overwriting .env or not”Examples in this section use .env to pass environment variables to a
container. The file .env can be overwritten when setting the environment
variable ENVFILE. This has few advantages:
- You know the file
.envwill always be used - Compose uses
.envwhen doing variable substitution
Another option is to change the Makefile in a way to use the specified file and
not overwrite the .env file with it.
Load envfile from Makefile
Section titled “Load envfile from Makefile”Most of the time, environment variables in envfile are passed to the containers via Docker and Compose. There are times when accessing variables before passing to Docker is handy. Environment variables in an envfile can be explicitly loaded from Make:
MESSAGE="Hello, World"# Snippet from https://lithic.tech/blog/2020-05/makefile-dot-envifneq (,$(wildcard ./.env)) include .env exportendif
echo: echo "$(MESSAGE)"However, this does not work well if the envfile contains key-only variables:
MESSAGEMake will return:
make echo#.env:1: *** missing separator. Stop.Given that it does not work well with key-only variables, I simply tend to define the variables directly in the Makefile:
MESSAGE ?= "Hello, World!"echo: echo "$(MESSAGE)"Check presence of env vars in Makefile
Section titled “Check presence of env vars in Makefile”Here is a way for checking the presence of environment variables before executing a Make target.
echo: env-ENV_MESSAGE @docker run --rm alpine echo "$(ENV_MESSAGE)"
env-%: @docker run --rm -e ENV_VAR=$($*) alpine echo "Check if $* is not empty" @docker run --rm -e ENV_VAR=$($*) alpine sh -c '[ -z "$$ENV_VAR" ] && echo "Error: $* is empty" && exit 1 || exit 0'make echo#Check if ENV_MESSAGE is not empty#Error: ENV_MESSAGE is empty#make: *** [env-ENV_MESSAGE] Error 1make echo ENV_MESSAGE=helloworld#Check if ENV_MESSAGE is not empty#helloworldAccess env vars in command argument
Section titled “Access env vars in command argument”# Executing the following will simply echo nothing even if ECHO is being passed.docker run --rm -e ECHO=musketeers alpine sh -c "echo $ECHO"# To access ECHO, either use '\'docker run --rm -e ECHO=musketeers alpine sh -c "echo \$ECHO"# Or use single quotedocker run --rm -e ECHO=musketeers alpine sh -c 'echo $ECHO'
# Info: Same applies with Compose.Tutorial
Section titled “Tutorial”This simple tutorial shows how environment variables and envfiles play together.
-
Final folder structure:
Directorytutorial
- compose.yml
- env.example
- env.template
- Makefile
-
Create the following 4 files:
env.template ENV_MESSAGEenv.example ENV_MESSAGE="Hello, World!"compose.yml version: "3.8"services:alpine:image: alpineenv_file: ${ENVFILE:-.env}volumes:- type: bindsource: "."target: /opt/appworking_dir: /opt/appMakefile COMPOSE_RUN_ALPINE = docker compose run alpineENVFILE ?= env.templateenvfile:ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) cp $(ENVFILE) .envshowMessage:$(COMPOSE_RUN_ALPINE) sh -c '\echo "# cat .env"; \cat .env; \echo "# env | grep ENV_MESSAGE"; \env | grep ENV_MESSAGE || true'clean:ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE) rm -f .envENVFILE=$(ENVFILE) docker compose down --remove-orphans -
Then run the following commands:
Terminal window unset ENV_MESSAGEmake cleanmake showMessage#Failed to load .env: no such file or directory# Create .env based on env.templatesmake envfilemake showMessage## cat .env#ENV_MESSAGE# env | grep ENV_MESSAGE#export ENV_MESSAGE="Hello!"make showMessage## cat .env#ENV_MESSAGE## env | grep ENV_MESSAGE#ENV_MESSAGE=Hello!# Create .env file based on env.example.# Keep in mind ENV_MESSAGE is still set to "Hello!"make envfile ENVFILE=env.examplemake showMessage## cat .env#ENV_MESSAGE="Hello, World!"## env | grep ENV_MESSAGE#ENV_MESSAGE=Hello, World!make cleanunset ENV_MESSAGE
Questions:
- Why does command
make showMessagefail if file.envis not present? - Why don’t commands
make cleanandmake envfilefail when file.envis not present? - What would be the main reason to use
ENVFILE=$(ENVFILE) $(COMPOSE_RUN_ALPINE)in targetsenvfileandcleanbut not in targetshowMessage? Hint: Do they need to have values from file.envfor their task? - Why is
ENV_MESSAGEin the lastmake showMessageset toHello, World!while it was set toHello!before?