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/my-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 and replace %SHA_384% with the content of this file : https://composer.github.io/installer.sig

# ./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') === '%SHA_384%') { 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/my-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 :)

  • 2019-04-16 Stéphane

    Hello

    Thank for your article.

    I have a problem when I launch the first command : docker-compose build

    At step 7/8 Service 'php' failed to build:COPY failed: stat /var/lib/docker/tmp/docker-builder705809157/home/stephane/Hubic/www/docker/apps/symfony-api: no such file or directory

    I put my file into /home/stephane/Hubic/www/docker/apps/ and I change the Symfony folder name in Dockerfile on line COPY apps/symfony-api

    On my system Ubuntu 18.04, there is no /usr/src/app. It's a problem ?

    Thank for your advise.

  • 2019-04-15 Nicolas NSSM

    You can just add a new Nodejs container to your docker-compose.yml file :
    https://hub.docker.com/_/node

  • 2019-04-12 Victor Toulouse

    Oh god… Docker on WSL? Why? I'm having enough trouble with just Docker for Windows 😅
    I'll check the blog, though.

    Anyway, yes, native bind mounts on Windows (they use, err… Samba, I think?) have catastrophic performance, but NFS with WinNFSd works. That's what some people use with Vagrant VMs on Windows, too.

  • 2019-04-12 payskin

    Hey Victor, thank you for the heads-up.

    The dev environment I use on Win10 is built upon connecting to the Docker for Windows daemon from WLS (Ubuntu 18.04 on the Linux subsystem). Check out this guy's blog: https://nickjanetakis.com/ he has a working solution for this setup (Win10 + Docker for Windows + Ubuntu 18.04 on WSL + native Docker CE and docker-compose on Ubuntu).

    In the mean time, I've managed to solve the mysql 8 authentication problem I mentioned above with a simple "command" line in the mysql section of docker-compose.yml that sets the default auth plugin to mysql_native_password. The very next day it turned out that our database was being hosted on Google Cloud SQL that does not support MySQL 8. :D So, I had to dial back to 5.7 which does not have this auth problem. Sigh.

    Regarding Symfony's performance in Docker, all the other backend devs around me use Ubuntu on their PCs (I'm the only Windows guy left), and the performance is way better in those environments, but like 4-5x times better. My 2500 ms request goes through in 500 ms or less on their PCs. Same code, same Docker setup, the only difference is in the dev enviroment. I'm looking forward to the new Win10 update in May and will reinstall my notebook and all the WSL stuff with it. We'll see if a fresh start helps or not.

    Cheers.

  • 2019-04-12 Victor Toulouse

    Ha, you're right! I was focusing on nginx and didn't even notice this.
    You need a named volume, or a bind mount (less efficient, but safer).
    I think this project can be trusted:
    https://github.com/Sylius/S...

  • 2019-04-12 Victor Toulouse

    I've been building a dev and testing environment for a 2.8 project for a few months now, on a Windows desktop.
    Performance is a huge issue.
    I managed to mitigate the problem using an NFS mount on the dev env, and moving cache and log directories out of the shared source directory.
    Moving the vendors also noticeably improves performance, but it has a few gotchas, and I've stopped doing it for now.

    I might write something about this, because I've been pulling my hair trying to get a working stack on both Windows and Linux, and there are flaws in every single post I've found including this one.

  • 2019-04-11 Victor Toulouse

    On you "production" docker-compose.yml, you did'nt specify any bind mount nor any context or Dockerfile.
    How is the public directory shared with nginx, then?

    You may copy the public directory to the nginx container, but if you run the symfony scripts in the php container, nginx won't know about compiled assets and symlinked vendor assets.

    Also, you first COPY your source code in the php container, then on the second example of your docker-compose.yml, you declare a bind mount.

  • 2019-04-05 Morgan Caron

    Hello ! Thank you very much for this article.
    With the given configuration, it seems that the database data will be lost every time the container is restarted or stopped.

    How do you manage your db data in production?

  • 2019-03-25 Luis Riego

    Hi! How can I add yarn to my projects?

  • 2019-03-24 payskin

    Great stuff, guys, thanks!

    What puzzles me, and I even checked a few repos of lcouellan, is that you simply install mysql 8 from image, and it works? How do you handle this new auth plugin it comes with? Adminer does not even able to connect it by default. I looked around and found hacks copying extra my.cnf files with default_authentication_plugin set to mysql_native_password. How is it that you don't have to deal with this? :)

    My other question is regarding speed. I follow Ryan's Sf4 tutorial course at KnpU (now SfC, I guess). Apart from a few hiccups with apcu, I had no problem following episode 1 and 2, but for Doctrine I needed MySQL, so I had to dockerize the whole project. That's how I found your solution. The good news: it works. Bad news: it's painfully slow. A simple page reload takes about 1500-2000 ms (even with cached volumes), and this is a very simple tutorial page. Again I looked around, and one of the probable causes might be the vendor or the cache folder, but since I'm not comfortable with Sf4 yet, I don't want to rearrange anything in the project structure. I use a hefty HP notebook with a recent i7 CPU and 16gigs RAM. What makes it even stranger, never had the same experience with Laravel and Slim projects which we run with a monstrosity called Laradock. What's your dev experience regarding this?

    Cheers.