Due to its flexibility and ease of usage, Docker has become one of the most widely used, if not the most widely used, methods of distributing software.
In most cases, putting an application inside an OCI image and distributing it through one of the popular image registries is pretty easy. But not in the case of a Laravel application. When you’re packaging a Laravel or PHP application in general, it’s not just the application.
Depending on what your application is doing, it’ll depend on PHP-FPM, composer, a bunch of packages and NGINX, at the very least. Although spinning up separate containers for php-fpm and NGINX is not that much of a hassle in development, in a production environment, the best way is to combine PHP-FPM and NGINX in a single image and develop as close to production as possible.
In this article, I’ll walk you through the process of containerizing a Laravel application for both the development and the production environment.
For your convenience, I’ve uploaded my code to this repository; feel free to fork and modify it.
One more disclaimer before I begin: Things like containerizing or deploying an application can be done in several ways. This is just one of them that I’ve used in the past. If you know a more optimized way of doing this, please share in the comments and take everything I say with a grain of salt.
Table of contents
Creating a Base Image
When it comes to containerizing a PHP or Laravel application, it’s common practice to use the official php image as a base. Nothing’s wrong with that, but that image doesn’t come with NGINX pre-installed. Even if you manage to install NGINX on a PHP image, it can be tricky to keep both NGINX and PHP-FPM running.
There are a number of pre-built base images out there that come with both PHP-FPM and NGINX. One such image that I often use, is the webdevops/php-nginx image. This is an excellent image with tons of customization options and easy-to-follow documentation.
In this article, I’ll not use any pre-built base Laravel docker image. Instead, I’ll show you how I make my own base image. The method I follow is not original. Bret Fisher has created the php-docker-good-defaults repository quite some time ago. I’ve made a fork of the repository, ported it from Debian to Alpine, and made necessary modifications as per my needs.
To follow along, you’ll need a Laravel application. Feel free to clone mine from this repository. Once you’ve cloned the code base, open it using Visual Studio Code or whatever code editor you like and take a good look at the directory structure.
.
├── Dockerfile
├── Makefile
├── docker
├── docker-compose.yaml
├── php-nginx-base.Dockerfile
└── src
This is how the project directory should look. The src
directory here contains the actual Laravel application code. The docker
directory contains mostly configuration files needed by Docker. The php-nginx-base
. Dockerfile
is the code for the base image (you’ll learn about it in this section). The Dockerfile
contains the code for the application image, derived from the aforementioned base image. The docker-compose.yaml
file is for using Compose in development and finally, the Makefile contains easy-to-execute one-liners for substituting commonly used long commands. This structure is nothing mandatory. You can come up with your own structure. Feel free to experiment.
Open up the php-nginx-base.Dockerfile
file. The code for this file is as follows:
FROM php:8.1.3-fpm-alpine3.15
ENV NGINX_VERSION 1.20.2
ENV NJS_VERSION 0.7.0
ENV PKG_RELEASE 1
# install necessary alpine packages
RUN apk update && apk add --no-cache \
zip \
unzip \
dos2unix \
supervisor \
libpng-dev \
libzip-dev \
freetype-dev \
$PHPIZE_DEPS \
libjpeg-turbo-dev
# compile native PHP packages
RUN docker-php-ext-install \
gd \
pcntl \
bcmath \
mysqli \
pdo_mysql
# configure packages
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# install additional packages from PECL
RUN pecl install zip && docker-php-ext-enable zip \
&& pecl install igbinary && docker-php-ext-enable igbinary \
&& yes | pecl install redis && docker-php-ext-enable redis
# install nginx
RUN set -x \
&& nginxPackages=" \
nginx=${NGINX_VERSION}-r${PKG_RELEASE} \
nginx-module-xslt=${NGINX_VERSION}-r${PKG_RELEASE} \
nginx-module-geoip=${NGINX_VERSION}-r${PKG_RELEASE} \
nginx-module-image-filter=${NGINX_VERSION}-r${PKG_RELEASE} \
nginx-module-njs=${NGINX_VERSION}.${NJS_VERSION}-r${PKG_RELEASE} \
" \
set -x \
&& KEY_SHA512="e7fa8303923d9b95db37a77ad46c68fd4755ff935d0a534d26eba83de193c76166c68bfe7f65471bf8881004ef4aa6df3e34689c305662750c0172fca5d8552a *stdin" \
&& apk add --no-cache --virtual .cert-deps \
openssl \
&& wget -O /tmp/nginx_signing.rsa.pub https://nginx.org/keys/nginx_signing.rsa.pub \
&& if [ "$(openssl rsa -pubin -in /tmp/nginx_signing.rsa.pub -text -noout | openssl sha512 -r)" = "$KEY_SHA512" ]; then \
echo "key verification succeeded!"; \
mv /tmp/nginx_signing.rsa.pub /etc/apk/keys/; \
else \
echo "key verification failed!"; \
exit 1; \
fi \
&& apk del .cert-deps \
&& apk add -X "https://nginx.org/packages/alpine/v$(egrep -o '^[0-9]+\.[0-9]+' /etc/alpine-release)/main" --no-cache $nginxPackages
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log
# copy supervisor configuration
COPY ./docker/supervisord.conf /etc/supervisord.conf
EXPOSE 80
# run supervisor
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
At first glance, this may seem very complicated, but it isn’t. The base image uses the official php image as its base. To be precise, the 8.1.3-fpm-alpine3.15
version of the official image. The first line of the code, FROM php:8.1.3-fpm-alpine3.15
sets the desired image as the base. When choosing the base, make sure to use a fixed Alpine version such as alpine3.15 and not the edge version. Otherwise, the NGINX installation step may fail. To figure out which Alpine versions are supported, check out NGINX’s package index page. At the time of writing, Alpine 3.15 is the latest supported version, hence I’m using the php:8.1.3-fpm-alpine3.15
image.
Then there are three ENV
instructions, setting three environment variables, NGINX_VERSION, NJS_VERSION
, and PKG_RELEASE
, needed for installing NGINX as you’ll see very soon.
After the environment variables, there is a single RUN
instruction for installing some necessary packages into the base image. If you’ve worked with Ubuntu or Debian in the past, apk
for Alpine Linux is what apt
is for Ubuntu or Debian. It’s a package manager. The apk update
command updates the package list. The apk add
command is for installing packages and the --no-cache
option instructs apk
to not cache the downloaded packages. This will keep the image smaller in size.
The zip
and unzip
packages are self-explanatory. dos2unix is needed for converting CRLF line endings to LF. supervisor is a process control system that is used for keeping both NGINX and PHP-FPM running at the same time. libpng-dev
, freetype-dev
, and libjpeg-turbo-dev
is needed for GD, a widely used graphics library. libzip-dev
is needed for igbinary serializer support and $PHPIZE_DEPS
is a collection of multiple packages needed for installing packages from PECL.
The second RUN
instruction uses the docker-php-ext-install
script to install some core PHP extensions. This is script included in all variants of the official PHP image and allows users to install any core PHP extensions. The bcmath
package is required by Laravel. The other ones are required by most of my projects. If you use a different RDMS such as PostgreSQL or Oracle, feel free to swap mysqli
and pdo_mysql
packages with whatever you need.
The next RUN
instruction uses the docker-php-ext-configure
package to configure packages installed using the docker-php-ext-install
script. The aforementioned GD package needs to be configured properly otherwise it may result in unexpected bugs. The docker-php-ext-configure
package is part of the official PHP image.
Not all packages are available through the PHP core. Such packages will have to be installed from PECL. The next RUN
statement installs some additional packages using pecl
. The zip
package is needed for working with archives. igbinary
is required by Redis if you want to enable the igbinary serializer support. Finally, I use Redis in more or less all my projects for caching, hence the redis
package is being installed. Any package installed from PECL has to be enabled manually using the docker-php-ext-enable
script. This script also comes built into the official PHP images.
Packages installed in this article are the ones I usually need in more or less all my projects. Feel free to cut some of them off or add any additional packages that you may need. It’s your base image, so make it your way.
After the PHP packages are installed, the next step is to install NGINX. As you can see, there is a long RUN
command that does the job of installing NGINX from the official website. Now, this code snipped here, is not written by me. This is how NGINX is installed in the official image. You can check out their repository for clarification. What this command does is determine the desired package versions from the environment variable you set at the top of the file, then based on the OS version, installs the appropriate NGINX package.
The next RUN
instruction links sdtout
and stderr
to NGINX access and error logs so that you can see the logs flowing on our terminal. The COPY
instruction copies the docker/supervisord.conf
to /etc/supervisord.conf
, the default configuration file location for supervisor.
The code for the configuration file is as follows:
[supervisord]
user=root
nodaemon=true
logfile=/dev/stdout
logfile_maxbytes=0
pidfile=/var/run/supervisord.pid
loglevel = INFO
[program:php-fpm]
command = /usr/local/sbin/php-fpm
autostart=true
autorestart=true
priority=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
[program:nginx]
command=/usr/sbin/nginx -g "daemon off;"
autostart=true
autorestart=true
priority=10
stdout_events_enabled=true
stderr_events_enabled=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
In this configuration file, the supervisord
section defines default configuration for supervisor. Then the two sections, program:php-fpm
and program:nginx
, daemonize PHP-FPM and NGINX to keep running side-by-side. If you want to learn more about configuring supervisor, feel free to consult the official documentation.
The EXPOSE
instruction is like documentation for services that need to know which ports are accessible in any Laravel docker container created from this image. NGINX runs in port 80
by default and that’s why it’s exposed. Finally, the CMD
instruction sets the default command of this image to ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
or in other words, starts supervisor by on startup. Supervisor then starts NGINX and PHP-FPM and you get a functional server.
That’s pretty much it for the base image. I don’t like to include composer in the base image because composer is likely to be updated more frequently than PHP or NGINX. I prefer installing composer in my derived images or application images.
Anyways, if you’re happy with your base image, you can build, tag, and upload the image to your Docker Hub account as follows:
docker image build -t <your-docker-hub-username>/<your-base-image-name>:<your-base-image-tag> .
docker image push <your-docker-hub-username>/<your-base-image-name>:<your-base-image-tag>
I’m assuming that you already know how to build Docker images. If you need help, feel free to check out my handbook on the topic. If you’ve successfully built, tagged, and uploaded your image, advance to the next section.
Creating an Application Image
Now that you have a base image to use, let’s create an application image. The Dockerfile
in my project contains the code for the application image. The code for the file is as follows:
FROM fhsinchy/php-nginx-base:php8.1.3-fpm-nginx1.20.2-alpine3.15
# set composer related environment variables
ENV PATH="/composer/vendor/bin:$PATH" \
COMPOSER_ALLOW_SUPERUSER=1 \
COMPOSER_VENDOR_DIR=/var/www/vendor \
COMPOSER_HOME=/composer
# install composer
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer \
&& composer --ansi --version --no-interaction
# install application dependencies
WORKDIR /var/www/app
COPY ./src/composer.json ./src/composer.lock* ./
RUN composer install --no-scripts --no-autoloader --ansi --no-interaction
# add custom php-fpm pool settings, these get written at entrypoint startup
ENV FPM_PM_MAX_CHILDREN=20 \
FPM_PM_START_SERVERS=2 \
FPM_PM_MIN_SPARE_SERVERS=1 \
FPM_PM_MAX_SPARE_SERVERS=3
# set application environment variables
ENV APP_NAME="Question Board" \
APP_ENV=production \
APP_DEBUG=false
# copy entrypoint files
COPY ./docker/docker-php-* /usr/local/bin/
RUN dos2unix /usr/local/bin/docker-php-entrypoint
RUN dos2unix /usr/local/bin/docker-php-entrypoint-dev
# copy nginx configuration
COPY ./docker/nginx.conf /etc/nginx/nginx.conf
COPY ./docker/default.conf /etc/nginx/conf.d/default.conf
# copy application code
WORKDIR /var/www/app
COPY ./src .
RUN composer dump-autoload -o \
&& chown -R :www-data /var/www/app \
&& chmod -R 775 /var/www/app/storage /var/www/app/bootstrap/cache
EXPOSE 80
# run supervisor
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
As you can see, this image uses my base image as it’s, well, base. Make sure to replace my base image name and tag with yours. The first thing you need is composer. The ENV
instruction sets some necessary environmental variable for composer.
Setting COMPOSER_ALLOW_SUPERUSER
to 1
lets you run composer as superuser without any warning. Setting COMPOSER_VENDOR_DIR
to /var/www/vendor
allows you to keep the installed packages in the /var/www/vendor
directory instead of a vendor
directory within the project.
Some Laravel developers prefer using a bind mount to protect the vendor
directory from getting overwritten but I prefer simply moving it out of the project directory. However, going this route will require you to update the artisan
and public/index.php
files in your project.
Open the artisan
file and update the require __DIR__.'/vendor/autoload.php';
line as follows:
require __DIR__.'/../vendor/autoload.php';
By default, the vendor
folder stays in the same directory as the artisan
file. But now instead of /var/www/app/vendor
it's in the /var/www/vendor
directory. Adding the additional ..
will make artisan
to go back one step in the directory tree. You have to make similar changes to the public/index.php
file as well. Open the file and replace the require __DIR__.'/../vendor/autoload.php';
line as follow:
require __DIR__.'/../../vendor/autoload.php';
Just like artisan
, you’ll need to add an extra set of dots before the vendor
directory path. If you think these changes are not ideal, feel free to use a bind mount instead or leave the vendor
directory as-is.
After setting the environment variables, the RUN
instruction uses curl
to download the official composer installer and installs it inside the /usr/local/bin
directory with the filename composer
. These installation instructions are taken directly from the official download instructions.
You can now use composer to install the application dependencies. To do so, you’ll have to first change the working directory to /var/www/app
, using the WORKDIR
instruction. You can use some other directory if you want. Then, you’ll have to use a COPY
instruction to copy the ./src/composer.json ./src/composer.lock*
files to the working directory. Since you’ve already configured the working directory, ./
refers to the working directory. Next, execute the composer install --no-scripts --no-autoloader --ansi --no-interaction
command with a RUN
instruction to install the dependencies. The --no-scripts
option will instruct composer to not execute any post-installation scripts. The --no-autoloader
option will prevent composer from generating the autoload file. Since we don’t have all the application code yet, generating the autoload at this point will not be ideal. The --ansi
option instructs composer to produce ANSI output and --no-interaction
means you want the package installation to be unattended.
Once the dependencies are installed, you may set any necessary environmental variables. I’ve set a bunch of environmental variables related to PHP-FPM. These variables will be later written to the PHP-FPM configuration. Feel free to get rid of them if you want to. Set any application related environment variable here but do not set anything sensitive like application encryption key or database password. They can be passed when starting the Laravel app container.
Now you’ll have to copy the entrypoint script. The dos2unix
utility converts the CRLF line endings to LF line ending. If you’re confused about the entrypoint, you can learn more from here. If you’re using my project, the entrypoint scripts should be docker/docker-php-entrypoint
and the code for this file is as follows:
#!/bin/sh
set -e
# write the php-fpm config
{ \
echo listen = /var/run/php-fpm.sock; \
echo listen.owner = www-data; \
echo listen.group = www-data; \
echo pm.max_children = "$FPM_PM_MAX_CHILDREN"; \
echo pm.start_servers = "$FPM_PM_START_SERVERS"; \
echo pm.min_spare_servers = "$FPM_PM_MIN_SPARE_SERVERS"; \
echo pm.max_spare_servers = "$FPM_PM_MAX_SPARE_SERVERS"; \
} > /usr/local/etc/php-fpm.d/zzz-app.conf
exec "$@"
This script writes the default configuration file for PHP-FPM. Setting listen
to /var/run/php-fpm.sock;
will instruct PHP-FPM to use the given UNIX socket for listening to requests. The listen.owner
and listen.group
options indicate the user and user group PHP-FPM should listen to. www-data
user and user groups exist in any Linux distribution by default. You can skip the rest of the four configuration options if want to.
There’s another file docker/docker-php-entrypoint-dev
but we’ll discuss that file later. The default location for the entrypoint script is /usr/local/bin/
so the COPY ./docker/docker-php-* /usr/local/bin/
will copy the entrypoint files and replace the default one.
Next, you’ll have to copy the NGINX configuration files. The docker/nginx.conf
file is the default NGINX configuration file. The code for the file is as follows:
user www-data;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
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"';
access_log /var/log/nginx/access.log main;
sendfile on;
#tcp_nopush on;
keepalive_timeout 65;
#gzip on;
include /etc/nginx/conf.d/*.conf;
}
This is more or less entirely the same as the default NGINX configuration file. The only change I’ve made is in the first line. By default, it says user nginx; but you want it to be user www-data;. Next, the docker/default.conf file is the configuration for the application itself. The code for this file is as follows:
server {
listen 80;
root /var/www/app/public;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
index index.php;
charset utf-8;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
error_page 404 /index.php;
location ~ \.php$ {
fastcgi_pass unix:/var/run/php-fpm.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
This code is copied from the official Laravel documentation. Make sure to change the root /var/www/app/public;
line as per your directory. Also, make sure fastcgi_pass unix:/var/run/php-fpm.sock``;
line matches the socket file location according to your entrypoint script.
The default location for the nginx.conf
file inside the image should be /etc/nginx/nginx.conf
and the default.conf
should be /etc/nginx/conf.d/default.conf
and that’s what the COPY
instruction does.
Finally, you can copy the application code. As you can see in the code, I’ve set the working directory once again. This is to make sure that the working directory is still what it should be and has not been overwritten. The COPY
instruction copies everything from the src
directory into the working directory.
Then the RUN
instruction executes the composer dump-autoload -o
command to generate the autoload file. Then the chown -R :www-data /var/www/app
command changes the directory owner group to www-data
and the chmod -R 775 /var/www/app/storage /var/www/app/bootstrap/cache
makes the /var/www/app/storage
, /var/www/app/bootstrap/cache
directories writable. These permission changes are necessary and you can learn more from this Guide to Deploying Laravel Applications on Virtual Private Servers. You should copy the application code as late as possible because it’s most likely to change.
After that, you just EXPOSE
the port 80
and set ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
as the default command. That’s pretty much it. Build the image and try to spin a new container to make sure everything’s working fine.
Creating The docker-compose.yaml File
Your project is now containerized. The image you built in the previous section is production-ready. The only thing that’s left to do is write a docker-compose.yaml
file for development. Open the docker-compose.yaml
file and the code for this file is as follows:
version: "3.8"
services:
db:
image: mysql:8.0
volumes:
- db-data:/var/lib/mysql
environment:
- MYSQL_ROOT_PASSWORD=root
- MYSQL_DATABASE=question_board
app:
build: .
entrypoint: /usr/local/bin/docker-php-entrypoint-dev
command: ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisord.conf"]
volumes:
- ./src:/var/www/app
environment:
- APP_ENV=local
- APP_KEY=base64:tLmYfUrrZITzLIkSjFnV+PCAFxkdU+duUxjVSIlrrHo=
- APP_DEBUG=true
- DB_CONNECTION=mysql
- DB_HOST=db ## same as the database service name
- DB_PORT=3306
- DB_DATABASE=question_board
- DB_USERNAME=root
- DB_PASSWORD=root
restart: unless-stopped
ports:
- 8000:80
depends_on:
- db
volumes:
db-data:
This is a regular docker-compose.yaml
file that you may have written in the past. There are two services. The db
service and the app
service. The db
service uses the mysql:8.0
image to create a new database container. It uses the db-data
named volume for persisting data. Since the volume is named, it’ll hang around between ups.
The app
service uses the Dockerfile
to build an image and start a container. The entrypoint: /usr/local/bin/docker-php-entrypoint-dev
line will override the default entrypoint script with the development entrypoint script. The code for this script is as follows:
#!/bin/sh
set -e
# run last minute build tools just for local dev
# this file should just be used to override on local dev in a compose file
# run default entrypoint first
/usr/local/bin/docker-php-entrypoint
# ensure bind mount permissions are what we need
chown -R :www-data /var/www/app
chmod -R 775 /var/www/app/storage /var/www/app/bootstrap/cache
# run last minute build tools just for local dev
cd /var/www/app
composer dump-autoload
cd /var/www/app/public
exec "$@"
This script runs the default entrypoint script first. Then it resets the directory permissions because in development, you’ll mount your local src
directory inside the container and it may mess up the permissions. After that, the script just regenerates the autoload. Since you’ve overridden the entrypoint, you’ll have to write the command once again.
The rest of the code mounts the src
directory to the /var/www/app
directory, sets the environment variables, exposes port 80 from inside the container to port 8000 on your host system. That’s it. You can use this file to start the containers in development mode.
There is a file Makefile
that contains the following code:
stop:
docker-compose stop
shell:
docker-compose exec app sh
start:
docker-compose up --detach
destroy:
docker-compose down --volumes
build:
docker-compose up --detach --build
seed:
docker-compose exec app php artisan db:seed
migrate:
docker-compose exec app php artisan migrate:fresh
You can use any of these commands like make start
to execute the attached docker-compose
command. Make may not be available on Windows, but you can always use WSL for that.
Conclusion
I would like to thank you for the time you've spent reading this article.
I hope you've enjoyed it and have learned some useful stuff regarding PHP, Laravel, and Docker.
If you want to learn Docker from the ground up, consider checking out my open-source Docker Handbook, which has tons of fun content and several practical projects.
Or, if you are more interested in node.js, here is an article on how to do containerizing Node.js applications with Docker.