Environment variables in a dockerized Symfony
2023-01-02
I have developed a Symfony Web-Application, and it runs locally in a dockerized environment with docker-compose. This app is going to be deployed to production as a docker container. In production the handling of environment variables and how they are passed to the container during development is different.
12 Factor App
A few points from the 12factor methodology:
- III. Config: Store config in the environment since env vars are easy to change between deploys without changing any code
- X. Dev/prod parity: Keep development, staging, and production as similar as possible
I was searching for options how to handle the differences how environment variables are passed and I found there are at least
7 ways to pass environment variables to a container
ENV
in dockerfile- Dockerfile args passed at build time to
ENV
- ENV passing in docker run as option
- Env_file in docker run as option
- Environment variables in
docker-compose.yml
- Env_file in docker compose for each service
.env
in docker compose substitutes variables indocker-compose.yml
And there is even more. If variables are passed to a container there is an order of precedence as follows:
- Passed from the command line
docker compose run --env <KEY[=[VAL]]>
. - Passed from/set in
compose.yaml
service’s configuration, from the environment key. - Passed from/set in
compose.yaml
service’s configuration, from the env_file key. - Passed from/set in Container Image in the ENV directive.
How to deal with environment variables in a dockerized Symfony
The goal
All services regardless of which technology they use, should have one streamlined way of how the environment variables should be passed to the application.
The big picture
- We use multiple services which all need to work together
- Services run in docker container
- We deploy and run services in different compositions for each environment
- Each service has their own sensitive data
- Each service might be a different technology or has a different tech stack
Steps towards the goal
- The infrastructure config should be kept in env files but not in the same directory as the application
- Each service gets its own env file to be completely independent of each other, and it gets explicitly set
- During development each service gets the env variables passed via env file (
env_file
in docker-compose) - Every project that has a
docker-compose.yml
moves the application into anapp
directory to separate the application from its infrastructure configuration - We remove the DotEnv component from symfony and define each environment variable that we expect as parameter so the app tells us instantly when a key-value pair is missing
- In development credentials can be added to the VCS
- In all other envs the credentials can be either stored and linked on the server or be read from a vault
The implementation
In Symfony the DotEnv component is default installed and enabled in the frontcontroller, so when a new app is created there is always a .env
file at the project root created with it. Read more in the documentation.
It is not the same .env
that docker-compose.yml
expects.
.env
to replace the variables in the docker-compose.yml
if it is located in the same directory.
If you don’t know that and put the web apps .env
file in the same place then you accidentally might overwrite variables when you think you just updated a variable for the Symfony application.We have two different stacks here that both want to use the .env
file and both might, but not at the same time, obviously.
Since we want to use config variables explicitly and not by accident the Symfony DotEnv component is going to be removed and all config is moved inside environment variable files that are passed into the container.
The directory tree
To ease the separation of infrastructure and code the application code moves into the ./app
directory to be completely separate from the code/config that defines the infrastructure.
You see there is no .env
file left from Symfony. All variables have now moved to the env files inside the devops/env
directory.
.
├── app
│ ├── assets
│ ├── bin
│ ├── ci
│ ├── config
│ ├── migrations
│ ├── node_modules
│ ├── public
│ │ └── index.php
│ ├── src
│ ├── templates
│ ├── tests
│ ├── var
│ ├── vendor
│ ├── composer.json
│ ├── composer.lock
│ ├── Makefile
│ ├── package.json
│ ├── symfony.lock
│ ├── webpack.config.js
│ └── yarn.lock
├── devops
│ ├── database
│ ├── docker
│ │ └── frankenphp
│ │ └── Dockerfile
│ └── env
│ ├── app.env
│ └── database.env
├── CONTRIBUTING.md
├── docker-compose.prod.yml
├── docker-compose.yml
├── Makefile
└── README.md
The docker-compose.yml
Each service gets its own env_file
where we can configure the sensitive data for each service.
version: '3.9'
services:
app:
image: ghcr.io/c-base/cbag3:dev-latest
build:
dockerfile: ./devops/docker/frankenphp/Dockerfile
target: dev
env_file: ./devops/env/app.env
ports:
- 80:80
- 443:443
volumes:
- './app:/app'
database:
image: postgres:alpine
container_name: database
env_file: ./devops/env/database.env
ports:
- 15432:5432
volumes:
- ./devops/database:/var/lib/postgresql
.env
file can be used with docker compose to configure variables inside the docker-compose.yml
Disable DotEnv in frontcontroller and console
The DotEnv component is disabled since all environment variables have already passed to the container.
# app/public/index.php
<?php
use Cbase\App\Kernel;
$_SERVER['APP_RUNTIME_OPTIONS']['disable_dotenv'] = true;
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
return function (array $context) {
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};
# app/bin/console
#!/usr/bin/env php
<?php
use Cbase\App\Kernel;
use Symfony\Bundle\FrameworkBundle\Console\Application;
$_SERVER['APP_RUNTIME_OPTIONS']['disable_dotenv'] = true;
require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';
return function (array $context) {
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
return new Application($kernel);
};
.env.test
.Don’t forget to add the parameters in services.yml
# app/config/services.yaml
parameters:
images.upload.directory: '%env(resolve:IMAGES_UPLOAD_DIRECTORY)%'
services:
_defaults:
autowire: true
autoconfigure: true
bind:
string $imagesUploadDirectory: '%images.upload.directory%'
Since every environment has its own env_file there is the danger of forgetting to add an environment variable to the other environments.
Run docker container in production with env-file
cat devops/env/app.env
# This is a comment
IMAGES_UPLOAD_DIRECTORY="%kernel.project_dir%/var/uploads"
docker run --env-file devops/env/app.env app env | grep -E 'IMAGES'
IMAGES_UPLOAD_DIRECTORY="%kernel.project_dir%/var/uploads"
Read more about it in the docker documentation.
Migration Path
There is a migration path for projects that use already many config yaml files and want to migrate to environment variables.
# config/my-app.yaml
parameters:
images.upload.directory: '%kernel.project_dir%/var/uploads'
# config/services.yaml
parameters:
env(IMAGES_UPLOAD_DIRECTORY): '%images.upload.directory%'
services:
_defaults:
bind:
string $imagesUploadDirectory: '%env(resolve:IMAGES_UPLOAD_DIRECTORY)%'
- the configuration processor looks up if there is an environment variable
IMAGES_UPLOAD_DIRECTORY
- if that is the case, it will be taken,
- otherwise if it is not found
'%images.upload.directory%'
will be set to the environment variable. - the
'%env(resolve:IMAGES_UPLOAD_DIRECTORY)%'
is bound to a variable$imagesUploadDirectory
Read more about configuration processors in the Symfony documentation about “Environment Variable Processors”.
This would result in the following migration path:
- Make it possible to set variables via environment variables
- Make sure all environments set the corresponding variables
- Remove many quirky unnecessary config files
- win
Conclusion
We removed the DotEnv from Symfony and will miss out on all the functionality that came with it, but chose using the env_file
as it can be used for running a container, and it can be configured in the docker-compose.yml
.
The environment configs can be dumped from secret vaults regardless of the tech-stack that the cloud has to offer or kept in a shared directory that won’t change between deployments.
There will be one explicit way of how each service will get configuration regardless of their environment or tech stack.
Also, we learned that there is a simple way in Symfony to migrate to environment variables.