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:7.1-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 is Composer
, 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.