How to dockerise a Symfony 4 project ?

Lénaïc and Antoine would like to  share with you their experience with Docker and Symfony 4.

Lénaic has recently joined KNP as a back-end developer, which does not stop him from playing with React on an internal project. You can follow him on github and twitter.

Antoine came to us 3 years ago as a Symfony Lover and developed a preference for front-end projects with React. You can follow him on github and twitter .

The aim of this article is to present a simple and complete step by step tutorial to bootstrap a Symfony 4 project running into Docker containers. Here is the list of what we need to run our application :

  • An HTTP server that serves our pages (i.e. Apache or Nginx)
  • A SQL database to store our application’s data (i.e. MySQL, MariaDB, PostgreSQL etc.)
  • A running instance of PHP fpm that handles PHP scripts and communicates with the HTTP server

These requirements are probably the most common ones if you want to develop a Symfony 4 application.

Why do we recommend Docker in your Symfony application ?

  • Widely reduces the cost of switching between different projects
    • You don’t have to handle several library versions on your machine. (For instance : you work on two projects that respectively use php 5.6 and php 7.2)
  • Widely reduces the time needed to install a project as a new developer
  • Allows to reproduce the production infrastructure (Same libraries, same versions)
  • Allows you to run your tests on Docker directly on the CI platform you use
    • The time when your tests were valid on your local machine but failed on the CI for some reasons is over.

First step : Install Docker

The first thing we need is to install Docker on your computer.

We need :

  • Docker
  • Docker-compose

Docker brings a simplified way to wrap our softwares into containers that includes everything it needs to run: code, runtime, system tools and libraries.

Docker-compose is a tool for defining and running multi-containers based applications. In particular, it brings everything we need to allow our containers to communicate to each other through a private network and shares filesystem area through volumes.

Install

If you are using a Ubuntu system, you will just have to do :

apt-get install docker docker-compose 

See https://docs.docker.com/install/ & https://docs.docker.com/compose/install/ for full documentation.

And it is … done. You have everything that you need on your computer.

Second step: organize the project directory

- apps/
   - my-symfony-app/ [← the actual root dir of your symfony app] 
 - bin/ 
 - docker/ 
 - .env 
 - docker-compose.yml 

Important note: This directory structure is just what we do at KNP Labs most of the time. It is not based on any convention and it is up to you to follow it or not. Just note that the following steps will be based on it and that you might have to adapt the code according to your own directory structure.

Third step : The docker-compose file

You need a file where you will list all the images required in your application.
In the root of your project, create a file “docker-compose.yml”

Now we will enumerate what we need in our Symfony application.

Setting up the database

First of all, we need a database engine. Let’s say MySql. You can of course change to Postgres or MariaDB.

Let’s try to create our file.

You can paste the following code :

 version:  '3.7'
 services:
   mysql:
     image: mysql:8.0
     restart: on-failure
     environment:
       MYSQL_ROOT_PASSWORD: root
       MYSQL_DATABASE: example 

What have we done ?

The first line specifies the docker-compose syntax version.
Then we add our list of services for our applications. It is a list of Docker images. You can see all images on Docker Hub : https://hub.docker.com/

We just added the latest version of mysql. We set up environment variable such as root password and database name. Full option is available here : https://hub.docker.com/r/library/mysql/

For those who like having a web interface, you can get adminer easily. Just add the following code :

version: '3.7'
services:
  mysql: 
     …

  adminer:
    image: adminer
    restart: on-failure
    ports:
      - ‘8080:8080’

The ports part is used for communicating with your containers. We link the port 8080 of your container to your computer. So you will be able to access adminer while going to http://localhost:8080/

Setting up the Http Server

Now that we have a database engine, we will need a http server such as Apache or Nginx. In this example, we will use Nginx.

In your docker-compose file you can add :

version: '3.7'

services:
  mysql: 
     …

  nginx:
    image: nginx:1.15.3-alpine
    restart: on-failure
    volumes:
      - './apps/my-symfony-app/public/:/usr/src/app'
    ports:
      - ‘80:80’

As you can see, there are more instructions than the mysql configuration.

You may notice that we provide a specific version of Nginx, “alpine”. You can chose the one you prefer on Docker Hub, but we recommend Alpine. Alpine is one of the lightest weight version of Linux, so it will cost less resources for your computer.

We will also link the port 80 to our computer (the default http port). The application will be able to run as http://localhost

We also have volumes. Volumes are used to share a file system area between your computer to your container. This is particularly useful in dev environment when you are constantly making changes in your source code. As the container and you local machine share the code through the volume, the container doesn’t have to be rebuilt each time a change occurs.

In this example, we use a Symfony 4 application, so we need to share the ‘public’ directory, if you are using a Symfony 3 application, you will have to share the ‘web’ directory. By convention, we store the source code into the ‘/usr/src/app’ directory of containers. That’s the reason why we have - ‘./public/:/usr/src/app’

We now have to tell nginx how to serve our project files. The following configuration is based on the one described in the official Symfony documentation

https://symfony.com/doc/current/setup/web_server_configuration.html .

On the root of your application, you can create a directory “docker”, in the directory, you can add a subdirectory “nginx”. Now you can add the configuration file given below named “default.conf”.

# ./docker/nginx/default.conf
server {
 server_name ~.*;

 location / {
     root /usr/src/app;

     try_files $uri /index.php$is_args$args;
 }

 location ~ ^/index\.php(/|$) {
     client_max_body_size 50m;

     fastcgi_pass php:9000;
     fastcgi_buffers 16 16k;
     fastcgi_buffer_size 32k;
     include fastcgi_params;
     fastcgi_param SCRIPT_FILENAME /usr/src/app/public/index.php;
 }

 error_log /dev/stderr debug;
 access_log /dev/stdout;
}

For the Symfony3 version, you can change “public” by “web” and “index.php” by “app.php”.

We still need to give this configuration file to our nginx container. This can be done by sharing this file through a volume.

You can add this line to your docker-compose.yml file:

- './docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro'

Your nginx service definition should now look like this:

nginx:
  image: nginx:1.15.3-alpine
  restart: on-failure
  depends_on:
    - php
  volumes:
    - './apps/symfony-app/public:/usr/src/app'
    - './docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro'
  ports:
    - '80:80'

Nginx is now configured.

Setting up the PHP Server

Our docker-compose is almost finished.

You can add the following lines :


  php:
    build:
      context: .
      dockerfile: docker/php/Dockerfile
    restart: on-failure
    env_file:
      - .env
    user: 1000:1000

In this container, we will share our application in the /usr/src/app. The nginx configuration file is already set up using this path so you have nothing else to do. I would still like to highlight one point:

In our default.conf file we have this line:

fastcgi_pass php: 9000 ;

This line means that we’ll transfer any incoming request to php-fpm on port 9000. Indeed, containers communicate to each other through a virtual network on your computer and on this network the “php” container can be reached through the name *drum roll*… “php” !  

One last update to add to our nginx service definition :

version: ’3.7′
services:
 mysql: 
    …

 nginx:
   image: nginx:1.15.3-alpine
   restart: on-failure
   volumes:
     - ‘./apps/my-symfony-app/public/:/usr/src/app’
     - ‘./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro’
   ports:
     - ’80:80′    
   depends_on:
     - php

Which means that the nginx container must be built and run after the php one.

Final poin t: why does the statement user: 1000:1000 stand for ?

This statement is used to tell the service that it will have to use the user with uid 1000 and gid 1000 internally. In most cases, the user we use on our local machine is the user with uid 1000 and gid 1000.

If you want to check your uid, just run the command :

$ id 

By adding this statement, both container and local system will interact with files (through the volume) with the exact same user (uid) and that will prevent you to have permission related issues.

Now there is another thing to do. As you can see, there is no image. We provide a Dockerfile. We will see now how to write it.

4th step : The DockerFile

The Dockerfile is used to give several instructions when we get our images. You can, for instance, install php extensions or execute unix commands.

In our example, you can create a “php” subdirectory in your “docker” directory. In this directory, you can create a file “Dockerfile”.

Now paste this code in your Dockerfile.

# ./docker/php/Dockerfile
FROM php:7.2-fpm

RUN docker-php-ext-install pdo_mysql

RUN pecl install apcu-5.1.8
RUN docker-php-ext-enable apcu

RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
    && php -r "if (hash_file('SHA384', 'composer-setup.php') === '93b54496392c062774670ac18b134c3b3a95e5a5e5c8f1a9f115f203b75bf9a129d5daa8ba6a13e2cc8a1da0806388a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \
    && php composer-setup.php --filename=composer \
    && php -r "unlink('composer-setup.php');" \
    && mv composer /usr/local/bin/composer

WORKDIR /usr/src/app

COPY apps/symfony-app /usr/src/app

RUN PATH=$PATH:/usr/src/apps/vendor/bin:bin

The code here, will use a Docker Image “php:7.2-fpm”.

After getting this image, the “pdo_mysql” library will be installed.

For more convenience, you tell Docker that the default directory will be “/usr/src/app”.

Then we copy our current application in our computer, to “/usr/src/app”.

We have now everything for running our app.

Last step : Run the app

Important note : You must be sure to free the ports of your local machine. For instance if you already have a running instance of nginx listening on port 80, be sure to switch this service off to allow your brand new container to use it.

 service  stop  nginx

We are almost done !
It is easy now. You just have to open a terminal at the root of your application.

Then run :

 docker-compose build 

This command will build everything we need from the “docker-compose.yml”.

Once your app is built. You can run :

 docker-compose up -d

To stop your containers, use :

 docker-compose stop  

Now you can run the Symfony commands for building your application on your Docker container such as :

docker-compose exec php composer install 
docker-compose exec php php bin/console doctrine:schema:create
docker-compose exec php php bin/console doctrine:fixtures:load
docker-compose exec php php bin/console assets:install –symlink public/

And voilà! You can now see your application running on Docker and available on : http://localhost/

To go further

Even if we have a running project on docker, there is still some improvements we can do to clean things.

First we hard coded some parameters inside our `docker-compose.yml` file. We will export these parameters inside a .env file which allows to override them with env vars.

Which variables will we export ?

  • Mysql password
  • Mysql database
  • All ports binding
  • The user the container must use

Let’s create a `.env` file in the project root directory :

MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=example

NGINX_PORT=80

ADMINER_PORT=8080

LOCAL_USER=1000:1000

Note: We highly recommend you to not version this file but create a duplicated `.env.dist` file that will be versioned so that each developer of your team can configure their own `.env` file.

Now let’s replace our hard coded parameters by our brand new env vars :

version: ’3.7′
services:
mysql:
  image: mysql:8.0
  restart: on-failure
  environment:
    MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    MYSQL_DATABASE: ${MYSQL_DATABASE}

php:
   build:
     context: .
     dockerfile: docker/php/Dockerfile
   restart: on-failure
   volumes:
     - ‘./apps/my-symfony-app/:/usr/src/app’
   user: ${LOCAL_USER}  

nginx:
   image: nginx:1.15.3-alpine
   restart: on-failure
   volumes:
     - ‘./apps/my-symfony-app/public/:/usr/src/app’
     - ‘./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro’
   ports:
     - ‘${NGINX_PORT}:80′    
   depends_on:
     - php
adminer:
   image: adminer
   restart: on-failure
   ports:
     - ‘${ADMINER_PORT}:8080‘

Now our docker-compose.yml can have env vars based configuration which allows each developer of the team to configure the project as they intend to. But we can go even further !

Let’s say we want to deploy these containers on production. Do we really need to share volumes and rebind ports between my containers and the host machine ? No. We need a way to configure such things in a separated place that just belongs to the host machine. docker-compose.override.yml to the rescue !

The docker-compose.override.yml is natively supported by docker-compose and allows you to override host specific configuration. This is what we will have at the end :

# docker-compose.yml
version: ’3.7′
services:
mysql:
  image: mysql:8.0
  restart: on-failure
  environment:
    MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    MYSQL_DATABASE: ${MYSQL_DATABASE}

 php:
   build:
     context: .
     dockerfile: docker/php/Dockerfile
   restart: on-failure
   user: ${LOCAL_USER}

 nginx:
   image: nginx:1.15.3-alpine
   restart: on-failure
   depends_on:
     - php

 adminer:
   image: adminer
   restart: on-failure
# docker-compose.override.yml
version: ’3.7′
services:
 php:
   volumes:
     - ‘./apps/my-symfony-app/:/usr/src/app’

 nginx:
   volumes:
     - ‘./apps/my-symfony-app/public/:/usr/src/app’
     - ‘./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro’
   ports:
     - ‘${NGINX_PORT}:80′

 adminer:
   ports:
     - ‘${ADMINER_PORT}:8080′

Now you have a really clean Docker configuration that is usable by anyone on your team and easily deployable on a real server !

If you have any question, feel free to ping us on twitter @KNPLabs :)

  • 2018-11-30 Antoine Lelaisant

    Hello Tom !
    May I suggest you to look at Webpack Encore https://symfony.com/doc/3.4... which is a library developed by Symfony and that allows to compile and copy your project css and js files into the 'public' folder. As soon as your assets are stored in the 'public' folder nginx should be able to load them properly.

  • 2018-10-12 tom

    Thanks for this post!
    It helped me a lot, I'm new to both symfony & docker, I'm using symfony 3.4 with skeleton which uses the 'public' folder, but it can't load the css files =/.

  • 2018-10-01 Lénaïc Couellan

    Hello,
    Thank you for supporting us !
    The indentation is now fixed.

  • 2018-09-30 Achilles Kaloeridis

    Thanks for sharing, great stuff!!

    I think after the "One last update to add to our nginx service definition" after the ports, the indentation is a little bit broken.

  • 2018-09-28 Eve Vinclair

    Hello ! Thank you very much ! We corrected it !

  • 2018-09-28 Manu

    Thank you for this interseting post ! (a new line is missing on : "user: ${LOCAL_USER} nginx:")