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.

  • A Symfony development environment using Docker
  • Setting up Symfony continuous integration using GitLab CI/CD
  • Setting up Symfony continuous deployment using Rancher

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

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.


Originally published at lekode.com on June 1, 2017.