A Symfony development environment using Docker

The containers ecosystem is gaining a lot of popularity right now, and as a web developer, using this architecture in my daily development workflow impacted my landscape for better.

In the next few articles, I will present to you the way I use Docker and its ecosystem to run a PHP development environment using Symfony as a framework, and how to deploy those containers to production.

The code for this article can be found here.

Let’s rock!

There are many articles on the internet that explain in details Docker, I will assume that you already have a working basic knowledge about it and about Symfony. The architecture of our environment will be as follow:

As mentioned in the official Docker website, it’s better to have a service per container, than having one container running NGINX and PHP services; we will have a container for the first and another one for the second service.

It is generally recommended that you separate areas of concern by using one service per container. That service may fork into multiple processes — Docker documentation

To follow the article better, the files tree will be as follow:

sf-project/  
├── app/
├── logs/
├── nginx/
├── php-fpm/
├── postgresql/
├── .env
└── docker-compose.yml

App

Our code will be in a separated container, based on busybox image, which is a light (1~5mb) Linux image. In the Dockerfile, we only need to link our application folder from the host to the container:

FROM busybox:latest  
ADD . /var/www/app  
CMD ["/bin/true"]  

NGINX

The NGINX service is based on the official Alpine NGINX image and contains some custom configuration. The Dockerfile will be simple for this container:

FROM nginx:alpine

RUN rm /etc/nginx/conf.d/default.conf  
ADD conf/nginx.conf /etc/nginx/nginx.conf  
ADD conf.d/lekode.conf /etc/nginx/conf.d/lekode.conf  

We remove the default NGINX configuration and add our custom configuration files.

Next, the nginx/conf/nginx.conf file, which is a basic NGINX configuration file, where we specify a format for the logs and activate gzip module.

http {  
    include /etc/nginx/mime.types;
    default_type application/octet-stream;

    log_format main '$remote_addr - $remote_user [$time_local] "$request" '
    '$status $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for"';

    sendfile on;

    keepalive_timeout 65;

    gzip on;
    gzip_disable "msie6";

    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain text/css application/json application/x-javascript
               text/xml application/xml application/xml+rss text/javascript;

    include /etc/nginx/conf.d/*.conf;
}

The server configuration file is, in general, the one I change from a PHP project to another. This file is located in nginx/conf.d/lekode.conf (the filename can be anything with a .conf extension).
First, let’s set the server name and root parameters, without forgetting to add the server name to our hosts' file.

server {  
    server_name lekode.dev;
    root /var/www/app/web;

Next, we set up the different locations for Symfony:

location / {  
    try_files $uri @rewriteapp;
}

location @rewriteapp {  
    rewrite ^(.*)$ /app_dev.php/$1 last;
}

location ~ \.php(/|$) {  
    fastcgi_pass php-fpm:9000;
    fastcgi_split_path_info ^(.+\.php)(/.*)$;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param HTTPS off;
}

It’s important to note this line here:
fastcgi_pass php-fpm:9000;

NGINX will proxy the requests to php-fpm container in the 9000 port. With the service discovery of Docker, we can use php-fpm service name as the hostname (the service name is defined in docker-compose.yml file which we’ll discover later in this article).

Finally, we save both access and errors logs in the container.

    error_log /var/log/nginx/lekode_error.log;
    access_log /var/log/nginx/lekode_access.log;
}

php-fpm

The php-fpm service besides of being based on the official php-fpm image needs several PHP extensions to fulfill the requirement of Symfony. So first thing in the Dockerfile, we set the base image and the WORKDIR variable which will be used to define where our application code will live:

FROM php:fpm-alpine  
ENV WORKDIR "/var/www/app"  

Next, we install a bunch of utilities and PHP extensions:

RUN apk upgrade --update && apk --no-cache add \  
    gcc g++ make git autoconf tzdata openntpd libcurl curl-dev coreutils \
    libmcrypt-dev freetype-dev libxpm-dev libjpeg-turbo-dev libvpx-dev \
    libpng-dev openssl-dev libxml2-dev postgresql-dev icu-dev

RUN docker-php-ext-configure intl \  
    && docker-php-ext-configure opcache \
    && docker-php-ext-configure gd --with-freetype-dir=/usr/include/ \
    --with-jpeg-dir=/usr/include/ --with-png-dir=/usr/include/ \
    --with-xpm-dir=/usr/include/

RUN docker-php-ext-install -j$(nproc) gd iconv pdo pdo_pgsql pdo_mysql curl \  
    mcrypt mbstring json xml xmlrpc zip bcmath intl opcache

# Install xDebug and Redis
RUN docker-php-source extract \  
    && pecl install xdebug redis \
    && docker-php-ext-enable xdebug redis \
    && docker-php-source delete

After that, we add the timezone (UTC for example):

RUN rm /etc/localtime && \  
    ln -s /usr/share/zoneinfo/UTC /etc/localtime && \
    "date"

Please note that the use of xDebug from a Docker container with a code editor is a little bit complex (at least for me I passed few hours before getting everything working correctly), so I will dedicate a separate article for it.

The last thing to install isComposer, I know that many people use a separate container to execute Composer commands, but I’m kind of lazy now, and we clean up everything:

# Install Composer
RUN curl -sS https://getcomposer.org/installer | \  
    php -- --install-dir=/usr/local/bin --filename=composer

# Cleanup
RUN rm -rf /var/cache/apk/* \  
    && find / -type f -iname \*.apk-new -delete \
    && rm -rf /var/cache/apk/*

Finally, we create the folder where the application will live, and set the right credentials for this folder and expose the php-fpm port:

RUN mkdir -p ${WORKDIR}  
RUN chown www-data:www-data -R ${WORKDIR}  
WORKDIR ${WORKDIR}  
EXPOSE 9000  
CMD ["php-fpm"]  

You can also use a php.ini file like this one (which will be mounted using docker-compose):

short_open_tag = Off  
magic_quotes_gpc = Off  
register_globals = Off  
session.auto_start = Off

upload_max_filesize = 100M  
post_max_size = 100M  
max_file_uploads = 20

max_execution_time = 30  
max_input_time = 60  
memory_limit = "512M"  

PostgreSQL

For the database service, an important thing will be to store the data folder in the host, or we will lose all our data once the container is restarted. The PostgreSQL official Docker image will be fine for us, so no need to build a new one.

Docker compose

To link all our services, we will use a docker-compose.yml file which describe our development environment. The services are defined as follow:

App

The app service will be like:

app:  
    build: ./app
    container_name: ${CONTAINER_PREFIX}.app
    volumes:
        - ./app:/var/www/app

NGINX

To access our application, we need to expose NGINX port 80, and to share the application code (from app container) with NGINX.

nginx:  
    build: ./nginx
    container_name: ${CONTAINER_PREFIX}.nginx
    ports:
        - "${NGINX_PORT}:80"
    volumes_from:
        - app
    volumes:
        - ./nginx/conf/nginx.conf:/etc/nginx/conf/nginx.conf:ro
        - ./nginx/conf.d:/etc/nginx/conf.d:ro
        - ./logs/nginx/:/var/log/nginx

php-fpm

To interpret PHP files, php-fpm container also needs to access those files from the app container.

php-fpm:  
    build: ./php-fpm
    container_name: ${CONTAINER_PREFIX}.php
    volumes_from:
        - app
    volumes:
        - ./php-fpm/conf.d/xdebug.ini:/usr/local/etc/php/conf.d/xdebug.ini
        - ./php-fpm/php.ini:/usr/local/etc/php/php.ini

PostgreSQL

PostgreSQL container needs some database information (host, username, and password), those information are injected as environment variables. In case of using an external client to access data, we need to expose the container’s port too.

postgresql:  
        image: postgres:alpine
        container_name: ${CONTAINER_PREFIX}.postgresql
        ports:
            - ${POSTGRES_PORT}:5432
        volumes:
            - ./postgresql:/var/lib/postgresql
            - ./logs/postgresql/:/var/log/postgresql
        environment:
            - POSTGRES_USER=${DB_USERNAME}
            - POSTGRES_PASSWORD=${DB_PASSWORD}
            - POSTGRES_DB=${DB_NAME}

MailDev

As a bonus, if the PHP application will send emails, it’s a brain teaser to set SMTP configuration in a local environment, a nice solution is to use some tools that catch the emails sent, and display them in a local dashboard, something like MailDev:

mailDev:  
    image: djfarrelly/maildev
    container_name: ${CONTAINER_PREFIX}.maildev
    ports:
        - "${MAIL_DEV_PORT}:80"

.env

Compose now supports .env file where we can set default values for our different environment variables, here is the one used in this stack:

# Global
CONTAINER_PREFIX=lekode.lab

# Ports
NGINX_PORT=80  
POSTGRES_PORT=5433  
MAIL_DEV_PORT=1080

# Database (Postgres)
DB_USERNAME=lekode  
DB_PASSWORD=secret  
DB_NAME=lekode  

Symfony

All we need to do now is to install Symfony into our app/ folder. Please note that since our app/ folder already contains a Dockerfile, we can’t use it directly with the Symfony installer, because the target project folder should be empty as explained in this issue. A workaround would be to install Symfony in a temporary folder, and move its content to our app/ folder:

symfony new sf_app  
cp sf_app/* ./app/  
rm -rf sf_app  

We can check the list of all our project running containers by running: docker ps

In case we want to run some Symfony commands like clearing cache or updating database, we need to login to the php-fpm container and run the commands (change lekode.lab.php by your php-fpm container name).

docker exec -ti lekode.lab.php sh  
php bin/console ...  

Conclusion

In this first article, I presented the #Docker development environment that I use for my most PHP projects, with some little customization to run Symfony, the next article will talk about a continuous integration workflow for this code using Gitlab CI/CD pipelines.

You can check this part’s code in my GitHub repository, if you have any question or note about this setup, please feel free to write a comment here.