Building Production-Ready Docker Images for PHP Apps
Docker is a powerful tool that allows you to package your applications along with all their dependencies into small, independent units called images. These images can then be easily deployed on any Docker-compatible orchestration platform, making it simple to ensure consistency across different development and production environments.
Besides providing increased portability, this opens up an opportunity for developers to run their code on a vast number of popular cloud application platforms without having to worry about any underlying infrastructure. This makes it a lot easier to build, test, and deploy PHP applications consistently and efficiently.
Broadly speaking, deploying PHP applications with Docker entails two steps: creating a Docker image and then deploying this image to the cloud as a container. The following article will focus on the first stage, namely, preparing a Docker image for production deployment.
This will include understanding what a Docker image is in the first place, learning about essential PHP internals, putting together a Dockerfile
, and ultimately building a fully customized Docker image for running a PHP application in production.
By the end of this article, you'll have a solid understanding of how PHP applications operate in a containerized environment and be able to leverage this knowledge to effectively deploy your PHP applications using Docker on any cloud platform of your choice.
Prerequisites
- Prior PHP development experience.
- Familiarity with the Linux command-line.
- Access to a Linux system with Docker Engine installed.
Creating a Docker image
Creating a Docker image starts with the Dockerfile
. This is just a text file containing instructions that Docker can interpret to produce an image. These instructions include specifying a base image, copying application-specific files, installing dependencies, and defining which command to run when a container is started. Once a Dockerfile
is created, it can be used to build an image using the docker build
command. After an image is built, it can be deployed and run as a container using the docker run
command.
It's easy to confuse the difference between an image and a container. You can think of an image as a blueprint. A container engine like Docker can use such blueprints to construct containers. Just as in object-oriented programming (OOP), where an object is an instance of a class, you can think of a container as an instance of an image—a concrete product made from the original blueprint.
When you invoke docker run
and specify an image name, the Docker Engine goes ahead and locates that blueprint, reads it, and constructs a container based on the instructions found within.
Among other things, the two most essential components that an image specifies are:
- Configuration data, providing details about the command to be executed inside the container, its default arguments, the environment variables available for injection, and many more.
- A group of filesystem layers forming the root filesystem of the container, housing all the software packages and libraries necessary for running the provided application, and isolating the container from the host system that it will be running on.
Choosing a base image
You need a Docker image capable of executing PHP code to run a PHP application. While you can certainly build one from scratch, it is usually more efficient and practical to start with a base image that already contains most of the dependencies needed to execute your application and extend it with whatever else is necessary.
One common choice is the official PHP image available on Docker Hub. This image comes in a variety of flavors, covering a lot of different PHP versions and setups. Each flavor contains a fully functioning PHP interpreter and additional tools, enabling easier customization. Being an official Docker image means it is regularly updated and maintained by the community, making it a great starting point for your PHP projects.
If your application can support this, it's always a good idea to run it on PHP's most recent stable release. This ensures that your application can use all the latest language features and security updates. You can find this information on the PHP supported versions page.
At the time of this writing, the latest actively supported version of PHP is 8.3
:
If you click on the 8.3
link, you'll be redirected to the PHP downloads page, where the most recent patch release of 8.3
is listed at the very top.
At the time of this writing, this version is 8.3.3
:
Let's explore the official PHP image on Docker Hub and search for version 8.3.3
:
This returns over 50 results! With so many options available, deciding which one to use as a base image for your application can be difficult. However, if you carefully examine all the available tags, you'll notice that exactly half of them begin with the prefix 8.3.3RC1
. These images are all release candidates, and you can safely discard them.
As explained on the official PHP QA page:
Release candidates are NOT for production use, they are for testing purposes only.
You can filter these out easily by typing 8.3.3-
instead of 8.3.3
in the search box:
This is, however, only a little better, as the updated result set still reports over 20 matching images. As previously discussed, these images correspond to different PHP flavors.
Every PHP version on Docker Hub is available in various image flavors. These are published automatically with every new PHP release, and their tag names generally adhere to the following naming schema:
<PHP version>-<variant>-<OS>
<PHP version>
is the semantic version number corresponding to the PHP release shipped with that particular Docker image; it is formatted asMAJOR.MINOR.PATCH
.<variant>
is one ofapache
,cli
,fpm
, orzts
.apache
supplies a PHP interpreter compiled as a shared Apache 2.0 module, bundled together with a complete installation of the Apache 2.0 web server.cli
supplies a PHP interpreter compiled for command-line usage (e.g., for executing PHP scripts from the terminal).fpm
supplies a PHP interpreter compiled as a FastCGI processor (e.g., for integrating with third-party web servers supporting the FastCGI protocol).zts
is almost the same ascli
but with Zend Thread Safety enabled in the PHP interpreter (hencezts
), allowing users to run multithreaded applications that rely on extensions such as pthreads (now deprecated) or parallel.
<OS>
refers to the base operating system image on which the PHP installation was carried out. Among other things, this determines the root filesystem's initial contents and what system packages and libraries are available by default. As new OS images are released and older ones are retired, the options available here change, but generally speaking, there's usually a branch based on a more lightweight Linux distribution (such as Alpine) and another one based on a more comprehensive distribution (such as Debian). For PHP8.3.3
in particular, the possible options are:bookworm
based on thedebian:bookworm-slim
image.bullseye
based on thedebian:bullseye-slim
image.alpine3.18
based on thealpine:3.18
image.alpine3.19
based on thealpine:3.19
image.
It's worth noting that none of the Alpine images have an apache
flavor. The image maintainers decided against this because the apache2
packages on Debian and Alpine have different default configurations. On Debian, Apache employs a hierarchical configuration file layout that makes adding and removing modules and configuration directives easier to automate. Supporting an apache
flavor would have required significant changes, so the maintainers decided not to do it. To some extent, it would have also drifted away from the lightweight philosophy of the Alpine image.
When running PHP applications in production, you'd almost surely want to put a web server such as NGINX or Caddy in front of your PHP application. While a popular choice, Apache may not be able to provide the same level of performance. Both NGINX and Caddy can interface with PHP through the FastCGI protocol, so you need to make sure that your image contains a PHP interpreter compiled as FastCGI processor. As we discussed above, the fpm
flavor offers precisely that, so you can further narrow your search down to 8.3.3-fpm
:
This reduces the result set to 6 matches.
As you're targeting a production environment, it's important to consider the size and efficiency of your image. Smaller images are generally preferred for production environments, as they reduce deployment times and improve scalability (they're faster to pull over the network and load into memory when starting containers). This makes each Alpine variant a better choice than the larger Debian variants.
Keeping the underlying Linux libraries up-to-date is also important, as this helps you safeguard your environment from potential security threats. Using a newer Linux image ensures that your container will run with the latest security patches and updates. Alpine 3.19 is therefore a more preferable choice than Alpine 3.18.
An image that fulfills all of these requirements is php:8.3.3-fpm-alpine3.19
:
Exploring php-fpm
The php:8.3.3-fpm-alpine3.19
image runs PHP as a FastCGI processor. This means that the PHP interpreter in this image is wrapped in a FastCGI server. The compiler outputs a binary file named php-fpm
when PHP is compiled as a FastCGI processor which contains both the PHP interpreter and the PHP FastCGI Process Manager.
When you launch a php-fpm
binary from the command line, it assumes the role of a master process. This master process initiates the FastCGI server, establishing a new TCP socket that (by default) listens on port 9000
for incoming connections, and forks as many php-fpm
worker processes as necessary in compliance with the specified worker pool configuration (2 children by default).
When a new FastCGI request arrives, one of the available worker processes accepts the incoming connection, locates the primary PHP script requested for execution, passes it through the PHP interpreter for processing, then transmits the generated output back to the original requester over TCP (in this case, the web server):
This is best illustrated with an example.
Start a new container using the php:8.3.3-fpm-alpine3.19
image:
docker run -d --rm --name fpm-example php:8.3.3-fpm-alpine3.19
The -d
option tells Docker to run this container in the background, the --rm
option ensures its removal after you're done working with it, and the --name
option assigns it the name fpm-example
so it's easier to refer to this container in subsequent commands.
Execute the following command:
docker exec fpm-example ps -o pid,ppid,user,args
The ps
command lists all processes currently running inside the container. The -o
argument alters the default output format by requesting the parent process ID (PPID
) to be included in the ps
response. This produces the following output:
PID PPID USER COMMAND
1 0 root php-fpm: master process (/usr/local/etc/php-fpm.conf)
7 1 www-data php-fpm: pool www
8 1 www-data php-fpm: pool www
9 0 root ps -o pid,ppid,user,args
The output confirms that the main process running inside the container is the php-fpm
master process, and that this process has two php-fpm
child processes, both belonging to the worker pool www
(the default worker pool that the php-fpm
container is configured to work with).
You can further use netstat
to see the socket that the master php-fpm
process is listening on:
docker exec fpm-example netstat -lp
This produces an output confirming that the main php-fpm
process has successfully established a listening socket on port 9000
and is ready to accept incoming connections on that port:
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 :::9000 :::* LISTEN 1/php-fpm.conf)
Active UNIX domain sockets (only servers)
Proto RefCnt Flags Type State I-Node PID/Program name Path
The FastCGI server that php-fpm
started seems to be up and running, but to give it some actual work and try it out, you'll be better off with a web server sitting in front of it. FastCGI is a binary protocol, and forming raw FastCGI requests can be quite tedious compared to sending HTTP requests to a web server and letting it perform the HTTP-to-FastCGI translation on your behalf.
You'll learn everything about this in the following section.
Configuring a web server
FastCGI (much like HTTP) is simply an application layer protocol. It uses TCP for transport (although it could also use Unix sockets locally) to transmit data encoded in a special binary format. That binary format is detailed in the official FastCGI specification, but its inner workings are beyond the scope of this article. The main point is that a raw FastCGI request cannot be easily represented in plain text, so forming one is, naturally, a little more involving than an HTTP request.
On the other hand, HTTP requests can be easily expressed in plain text, making them a lot easier for human users to construct and comprehend. Web browsers, REST APIs, and associated tools can all speak HTTP fluently but have a hard time communicating in FastCGI. That's where web servers like NGINX can help by converting your HTTP requests to FastCGI and then forwarding them to php-fpm
for processing.
Let's add an nginx
container and configure it to communicate with the php-fpm
container you launched earlier.
Just like PHP, NGINX has an official image available on Docker Hub. And just like PHP, this image comes in many different flavors, so you need to go through a similar cadence.
Navigate to the NGINX download page and locate the latest available release:
You'll see a mainline version and a stable version. There's usually some confusion when it comes to choosing the right version for a production environment. The official NGINX installation guide makes the following suggestion:
To stay on the safe side, steer towards the stable version. Navigate to Docker Hub and search for version 1.24.0
:
This reduces the result set to 10 matches, corresponding to all the different nginx
image flavors. The naming schema for NGINX images is not as straightforward as with the official PHP image, but it can still be roughly expressed as:
<NGINX version>-<OS>-<variant>
There are some specifics to be aware of, though.
In general, you can still choose between a lightweight Alpine version and a comprehensive Debian version, so you can narrow your search to the Alpine versions alone for the same reasons as before:
This leaves you with six flavors to choose from. Half of them are tagged as 1.24.0-alpine3.17
and the other half as just 1.24.0-alpine
. It may not be immediately obvious, but these options are not identical. The alpine3.17
images are based on Alpine 3.17, while the alpine
images are based on the newer Alpine 3.18 distribution. For reasons explained earlier, you'd want to use the newer version, so this leaves you with three potential options:
1.24.0-alpine
1.24.0-alpine-perl
1.24.0-alpine-slim
You might be thinking that nginx:1.24.0-alpine
is just an alias for one of the other two options (like in the PHP image where php:8.3.3-alpine
aliases 8.3.3-cli-alpine3.19
), but that's not the case here. nginx:1.24.0-alpine
is a whole different flavor that adds things on top of nginx:1.24.0-alpine-slim
.
In general, the nginx
image hierarchy looks like this:
nginx:1.24.0-alpine-slim
is the smallest possible image, installing NGINX directly onalpine:3.18
.nginx:1.24.0-alpine
is a slightly larger image, bringing in some additional NGINX dynamic modules, such as GeoIP, Image-Filter, njs, and XSLT.nginx:1.24.0-alpine-perl
further introduces the Perl module (in addition to the 4 modules mentioned previously).
None of the dynamic modules listed above are mandatory for getting php-fpm
integrated with NGINX. Therefore, you can safely use nginx:1.24.0-alpine-slim
as your NGINX image in this tutorial.
Go ahead and start a new nginx
container:
docker run -d --rm --name nginx-example -p 8080:80 nginx:1.24.0-alpine-slim
In addition to the -d
, -rm
, and --name
options you previously specified when starting the php-fpm
container, the -p 8080:80
option tells Docker to map port 8080
on the host machine to port 80
inside the container, allowing you to access NGINX through localhost:8080
.
Navigate to localhost:8080
to verify that everything works. You should see the following welcome page:
When you launch NGINX, it starts with a very minimal default configuration. The default virtual server is defined in /etc/nginx/conf.d/default.conf
and its initial settings boil down to:
You'll have to modify these settings to enable communication with the php-fpm
server started earlier. Create a new file locally named default.conf
and paste the following contents:
As you can see, the only significant changes are the removal of the error_page
directive and its related location
block, and the addition of a new location
block telling NGINX how to deal with *.php
files.
Removing the error_page
directive prevents NGINX from displaying the default error page when encountering 5XX
errors. The default error page looks like this:
By excluding it, 5XX
errors will be shown in the following format instead:
This will come in handy later when you troubleshoot the connectivity between php-fpm
and nginx
, as the HTTP error code is now clearly visible on the error page.
As for the newly introduced location
block:
location ~ \.php$ {
fastcgi_pass 127.0.0.1:9000;
fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
include fastcgi_params;
}
location ~ \.php$
tellsnginx
to capture all HTTP requests to URIs ending in.php
(e.g.,http://localhost:8080/test.php
is one such URL).fastcgi_pass 127.0.0.1:9000
tellsnginx
to translate such requests to FastCGI and pass them for processing to the FastCGI server located at127.0.0.1:9000
.fastcgi_param SCRIPT_FILENAME
andinclude fastcgi_params
tellnginx
to add a specific set of key-value pairs (think request variables) to the FastCGI request before sending it to the FastCGI server at127.0.0.1:9000
for processing.
To apply the updated configuration, copy the newly created file to the nginx
container by issuing:
docker cp default.conf nginx-example:/etc/nginx/conf.d/default.conf
Then, force nginx
to reload its configuration by typing:
docker exec nginx-example nginx -s reload
Output:
2024/03/03 08:27:44 [notice] 46#46: signal process started
Now, try to access a PHP script from your browser (e.g., test.php
):
A 502 Bad Gateway error appears.
Examine the logs from the NGINX container:
docker logs -n 5 nginx-example
The -n 5
option limits the output to the five most recent log entries. Among them, you'll notice:
2024/03/03 09:19:22 [error] 48#48: *1 connect() failed (111: Connection refused) while connecting to upstream, client: 172.17.0.1, server: localhost, request: "GET /test.php HTTP/1.1", upstream: "fastcgi://127.0.0.1:9000", host: "localhost:8080"
172.17.0.1 - - [03/Mar/2024:09:19:22 +0000] "GET /test.php HTTP/1.1" 502 559 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "-"
The logs indicate that fastcgi://127.0.0.1:9000
refused the connection request initiated by NGINX.
This makes perfect sense because 127.0.0.1
is a loopback address that points to the same container that the nginx
process is running in, whereas the php-fpm
process is running inside a whole different container.
To locate the IP address of the php-fpm
container, type:
docker inspect -f '{{.NetworkSettings.IPAddress}}' fpm-example
You'll get a similar output:
172.17.0.2
Change the NGINX config to reflect this address:
Copy the updated configuration file to the nginx
container once again:
docker cp default.conf nginx-example:/etc/nginx/conf.d/default.conf
Then force another reload of the configuration:
docker exec nginx-example nginx -s reload
This time localhost:8080/test.php
returns a different error:
Examine the NGINX logs again:
docker logs -n 5 nginx-example
Towards the bottom, you'll see a message stating:
172.17.0.1 - - [03/Mar/2024:09:30:45 +0000] "GET /test.php HTTP/1.1" 404 27 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36" "-"
The Connection refused
error is no longer present, so it appears that nginx
successfully connected to the php-fpm
upstream. What might be causing the 404 error, then?
The answer lies in the php-fpm
logs:
docker logs -n 5 fpm-example
There, you'll find a similar message:
172.17.0.3 - 03/Mar/2024:09:30:45 +0000 "GET /test.php" 404
Indeed, the nginx
request reached the php-fpm
server; however, php-fpm
was unable to locate a script with the name test.php
locally and was therefore unable to fulfill the request.
This also makes perfect sense. You launched the php-fpm
container without adding any PHP scripts, so its working directory is practically empty.
You can determine the default working directory of the php-fpm
container by typing:
docker inspect -f '{{.Config.WorkingDir}}' fpm-example
As the output confirms, this defaults to /var/www/html
:
/var/www/html
Issue the following command to examine its contents:
docker exec fpm-example ls -Al /var/www/html
Indeed, the directory is empty:
total 0
Create a new file named test.php
locally and populate it with the following contents:
Copy test.php
to /var/www/html
in the container by typing:
docker cp test.php fpm-example:/var/www/html
List the contents once again, and you should see the new file there:
docker exec fpm-example ls -Al /var/www/html
Output:
total 4
-rw-rw-r-- 1 1000 1000 18 Mar 3 09:46 test.php
Nothing seems to have changed if you repeat the HTTP request, though:
Why is that? The answer lies back in the PHP location
block of your NGINX configuration:
The SCRIPT_FILENAME
FastCGI parameter is what determines the exact location of a PHP script inside the php-fpm
container. The $fastcgi_script_name
variable contains only the name of that script (in your case, test.php
). When php-fpm
receives test.php
as the SCRIPT_FILENAME
, it tries to locate it in the root filesystem folder (/test.php
). However, there's no such file inside the php-fpm
container, which you can verify by typing:
docker exec fpm-example ls -Al /test.php
Output:
ls: /test.php: No such file or directory
The script is actually located at /var/www/html/test.php
, and your NGINX configuration needs to reflect this. Modify it one last time as follows:
By adding the root
directive, you're explicitly stating that PHP scripts are located at /var/www/html
inside the php-fpm
container. You're then further passing this value when forming the SCRIPT_FILENAME
FastCGI parameter by referencing the $document_root
variable, so the FastCGI server will now be looking for a /var/www/html/test.php
instead of /test.php
.
You know the drill now. Copy the updated configuration to the NGINX container, then reload it:
docker cp default.conf nginx-example:/etc/nginx/conf.d/default.conf
docker exec nginx-example nginx -s reload
Now refresh localhost:8080/test.php
:
It works! The phpinfo()
page loads up successfully.
Well done! You have configured NGINX and php-fpm
to work together. In the next section, you'll expand on this knowledge and build a custom image for running an actual PHP application (a simple REST API built with Laravel).
You're not going to need the nginx-example
and fpm-example
anymore, so please remove them before continuing further:
docker stop nginx-example fpm-example
Building custom images for PHP applications
From this point on in the tutorial, you'll be working with a small Laravel application called the Product API. The Product API is a very basic REST API that allows users to create, read, update, and delete products. It will serve as a practical example for you to expand what you've learned so far and produce a fully customized Docker image for running a PHP application.
Go ahead and clone the application repository locally:
git clone https://github.com/betterstack-community/product-api.git
To run a PHP application as a Docker container in production, you need at least two things:
- Its source code should be present inside the container image itself.
- The PHP interpreter inside the container image should be capable of executing that source code (i.e., it should run the correct PHP version and have all required PHP extensions).
The Product API source code is certainly not available in any of the official PHP images, so you need to find a way to add it. This is an excellent reason for creating a custom Docker image.
Start by creating a new Dockerfile
within your product-api
project:
cd product-api
touch Dockerfile
Open up your Dockerfile
and add the following line:
The FROM
instruction specifies a base image for your PHP application. As you saw earlier, php:8.3.3-fpm-alpine3.19
is a good starting point. You'll be putting the Product API behind NGINX, and the php:8.3.3-fpm-alpine3.19
image already contains a PHP interpreter compiled to run as a FastCGI processor, so a lot of the groundwork has already been carried out for you.
The next step is to add the application source code to the image. You can accomplish this through the COPY
instruction. As you remember, the default working directory in the php:8.3.3-fpm-alpine3.19
image is /var/www/html
, so you can store your application files there (you can always customize the working directory through the WORKDIR
instruction if you find it necessary, but let's stick with the default one for now).
Modify your Dockerfile
as follows:
This will copy the entire contents of your product-api
directory into the image.
You can now try building an image from this Dockerfile
by running the following command:
docker build -t product-api:0.1.0 .
After a moment, the build completes, and a new image tagged as product-api:0.1.0
appears in your local library.
You can confirm this with:
docker image ls product-api
Output:
REPOSITORY TAG IMAGE ID CREATED SIZE
product-api 0.1.0 e1ad9dcffccc 3 minutes ago 83.6MB
Go ahead and launch a container from this image:
docker run --rm --name product-api product-api:0.1.0
The container starts successfully, with php-fpm
reporting that it's ready to start accepting connections:
[03-Mar-2024 10:57:53] NOTICE: fpm is running, pid 1
[03-Mar-2024 10:57:53] NOTICE: ready to handle connections
Hit Ctrl+C
to terminate the container. There is some preliminary work you need to do before introducing the NGINX server that the product-api
container will be operating behind.
Remember how, in earlier examples, you had to determine the IP address of the php-fpm
container manually to be able to configure NGINX properly? In a production environment, you can't rely on doing this by hand.
You can, however, use DNS resolution to route traffic to the correct container automatically. Setting this up varies from provider to provider, but locally, you can use Docker's embedded DNS server, which routes traffic based on the container's name.
To set this up, you only need to attach your containers to a custom bridge network.
To create one, run:
docker network create product-api-network
Now, start a new product-api
container and attach it to that network using the --network
option:
docker run -d --rm --name product-api --network product-api-network product-api:0.1.0
Product API is a Laravel application and the official Laravel documentation already provides a sample configuration file for NGINX that you can use as a starting point for configuring your web server.
You'll still have to tweak a couple of things, though:
- Change the
root
directive in the mainserver
block to/usr/share/nginx/html
to match the default workdir of the officialnginx
image. - Change the
try_files
directive in thelocation /
block to$uri /index.php?$query_string
. This prevents NGINX from trying to output directory listings and redirects all requests for non-existent static files tophp-fpm
directly. - Change the content of the
location ~ \.php$
block to:
root /var/www/html/public;
fastcgi_pass product-api:9000;
fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
include fastcgi_params;
These changes will ensure that the IP address of the FastCGI server running inside the php-fpm
container will be resolved using the product-api
DNS name. Furthermore, it will ensure that the FastCGI server will look for the Laravel public
folder using /var/www/html/public
rather than /usr/share/nginx/html
as the file path.
The final file should read:
You may optionally also remove the server_name
directive and add a default_server
parameter to the listen
directives instead, as follows:
This is, however, not required for running this example locally, as the default nginx
image comes with only one virtual server defined, and on such occasions, that server automatically assumes the role of a default server for all address:port
pairs specified.
Without further ado, go ahead and start the NGINX server:
docker run -d --rm --name web-server --network product-api-network -p 8080:80 -v ./laravel.conf:/etc/nginx/conf.d/default.conf nginx:1.24.0-alpine-slim
Using the -v
option replaces the /etc/nginx/conf.d/default.conf
file inside the container with the laravel.conf
you created through a volume mount. This ensures that further updates to that file will automatically get propagated to the container, sparing you from having to issue the following command over and over again:
docker cp laravel.conf nginx-example:/etc/nginx/conf.d/default.conf
Open up a new browser tab and navigate to localhost:8080
:
NGINX seems to be successfully communicating with php-fpm
, which is great news! However, the error indicates that the Product API cannot locate its autoload.php
file. This is another important thing to keep in mind. To be able to run your PHP applications in production, you must ensure that all required dependencies are available inside the container. You cloned the repository and copied the source code to the container image but not the Composer dependencies.
The official PHP image doesn't come with Composer pre-installed, so you have to find a way to bring it into your custom image to be able to install the required dependencies. You can achieve this through a multi-stage build, using the official Composer image. You can just use the latest version of Composer, which at the time of this writing is 2.7.1
.
Go ahead and modify your Dockerfile
as follows:
These changes will tell the Docker Engine to load the /usr/bin/composer
file from the composer:2.7.1
image and place it into your custom PHP image.
This now allows you to run composer install
to fetch the required dependencies:
With all of these changes in place, go ahead and build a new version of the image:
docker build -t product-api:0.2.0 .
Terminate the previous product-api
container and replace it with a container running the newest image:
docker stop product-api
docker run -d --rm --name product-api --network product-api-network product-api:0.2.0
Refresh localhost:8080
in your browser:
It seems like the autoloader issue is now gone, yet you are getting a cryptic 500 Internal Server Error.
There's not much information in the product-api
container logs as well:
docker logs -n 5 product-api
Output:
[04-Mar-2024 07:58:57] NOTICE: fpm is running, pid 1
[04-Mar-2024 07:58:57] NOTICE: ready to handle connections
172.19.0.3 - 04/Mar/2024:07:59:14 +0000 "GET /index.php" 500
Something is concealing the specific problem causing the error.
Fortunately, Laravel has a mechanism that allows putting your application in debug mode. This enables a very verbose level of error logging, which can often aid you in pinpointing the exact issues in your application. Debug mode is activated by setting the APP_DEBUG
environment variable to true
before booting up your application.
Create a new file named laravel.env
and add the following line:
You'll use this file to pass environment variables to the product-api
container at runtime.
Stop the product-api
container first with:
docker stop product-api
Then restart it with the following command:
docker run -d --rm --name product-api --network product-api-network --env-file laravel.env product-api:0.2.0
The --env-file
option reads environment variable definitions from the laravel.env
file and makes them available to the product-api
container.
Refresh localhost:8080
once again:
The error indicates that Laravel is unable to open the /var/www/html/storage/laravel.log
file for writing due to a permission denied error. The cause may not seem immediately obvious, but it can be easily identified with little investigation.
Execute the following command:
docker exec product-api ps
Note how the php-fpm: pool www
processes run as the www-data
user:
PID USER TIME COMMAND
1 root 0:00 php-fpm: master process (/usr/local/etc/php-fpm.conf)
7 www-data 0:00 php-fpm: pool www
8 www-data 0:00 php-fpm: pool www
9 root 0:00 ps
Now run:
docker exec product-api ls -l /var/www/html
Note how all application files and folders (including the storage
directory in question) are owned by the root
user:
total 368
-rw-rw-r-- 1 root root 4109 Mar 3 10:24 README.md
drwxrwxr-x 7 root root 4096 Mar 3 10:24 app
-rwxrwxr-x 1 root root 350 Mar 3 10:24 artisan
drwxrwxr-x 1 root root 4096 Mar 3 10:24 bootstrap
-rw-rw-r-- 1 root root 2029 Mar 3 10:24 composer.json
-rw-rw-r-- 1 root root 301938 Mar 3 10:24 composer.lock
drwxrwxr-x 2 root root 4096 Mar 3 10:24 config
drwxrwxr-x 5 root root 4096 Mar 3 10:24 database
-rw-rw-r-- 1 root root 244 Mar 3 10:24 package.json
-rw-rw-r-- 1 root root 1191 Mar 3 10:24 phpunit.xml
drwxrwxr-x 2 root root 4096 Mar 3 10:24 public
drwxrwxr-x 5 root root 4096 Mar 3 10:24 resources
drwxrwxr-x 2 root root 4096 Mar 3 10:24 routes
drwxrwxr-x 5 root root 4096 Mar 3 10:24 storage
drwxrwxr-x 4 root root 4096 Mar 3 10:24 tests
drwxr-xr-x 41 root root 4096 Mar 4 07:57 vendor
-rw-rw-r-- 1 root root 263 Mar 3 10:24 vite.config.js
The system prevents writes to the /var/www/html/storage/laravel.log
file, because the www-data
user is neither the owner of this file nor a member of the root
user group.
The best way to fix this and any subsequent file permission errors is to ensure that all the application files that are packaged into your Docker image belong to the same user and group as the one running the php-fpm
worker processes.
With that in mind, modify your Dockerfile
as follows:
The --chown
parameter to the COPY
instruction ensures all application files will be owned by the www-data
user inside the container.
This will take care of the application files but won't affect the vendor
folder, as commands inside the Docker container run as root
by default. You'll therefore need a USER
instruction right before RUN
to ensure that composer
will run and install packages as the www-data
user:
This will, however, affect the php-fpm
master process, which requires running as root
, so as a last step, make sure to restore root
as the default user:
Go ahead and rebuild the image:
docker build -t product-api:0.3.0 .
Then, stop the old container and replace it with a new one running the most recent image:
docker stop product-api
docker run -d --rm --name product-api --network product-api-network --env-file laravel.env product-api:0.3.0
Refresh the page. This time, the error says:
Indeed, Laravel requires an application key to boot up, but it seems you solved the original file permission error.
Typically, an application key is generated locally (during development) and supplied as an environment variable in production.
Assuming you already have this key generated locally, place it in your laravel.env
file as follows:
If you can't generate one yourself right now and want to continue with the tutorial, feel free to use the following pre-generated key, but please do not use this key in an actual production application! Application keys are sensitive information and should always be kept secret!
To enable the newly added environment variable, recreate the product-api
container:
docker stop product-api
docker run -d --rm --name product-api --network product-api-network --env-file laravel.env product-api:0.3.0
Refresh the page again. This time, the application redirects you to localhost:8080/api/products
:
Good! The file permission and application key errors are both gone, but now an SQL error appears because the required database driver seems to be missing from the underlying PHP installation.
Let's solve this problem in the next section!
Installing PHP extensions
Unless otherwise specified, the Product API uses the MySQL driver by default. Thus far, you've never had to configure a MySQL database for the application, so now is a good time to do that. For that purpose, you can use the official MySQL image.
With MySQL, you can choose between an innovation and a stable release version. This is similar to the mainline/stable release strategy employed by NGINX, so let's stick to using the latest stable MySQL version, which at the time of this writing is 8.0.36
.
Launching a new MySQL container requires some preliminary setup. Namely, certain environment variables have to be specified.
Go ahead and create a new file named mysql.env
with the following contents:
Then, launch the MySQL container by typing:
docker run -d --rm --name db --network product-api-network --env-file mysql.env mysql:8.0.36
You also need to configure Laravel to communicate with this container.
For that purpose, modify your laravel.env
file as follows:
Recreate the Product API container, as usual, to pick up the environment variable changes:
docker stop product-api
docker run -d --rm --name product-api --network product-api-network --env-file laravel.env product-api:0.3.0
You can now start troubleshooting the could not find driver
error.
In the PHP world, could not find driver
is a well-known PDOException
message. The PDO extension (PHP Data Objects) supplies a database abstraction layer, providing developers a unified interface for integrating with databases built by different vendors. However, each database requires a vendor-specific PDO driver to work properly.
By default, the PHP interpreter inside the official PHP image comes pre-compiled with only the PDO SQLite driver and nothing more.
You can confirm this by running:
docker exec product-api php-fpm -m
The output lists all extensions available to php-fpm
inside the product-api
container:
[PHP Modules]
cgi-fcgi
Core
ctype
curl
date
dom
fileinfo
filter
hash
iconv
json
libxml
mbstring
mysqlnd
openssl
pcre
PDO
pdo_sqlite
Phar
posix
random
readline
Reflection
session
SimpleXML
sodium
SPL
sqlite3
standard
tokenizer
xml
xmlreader
xmlwriter
zlib
[Zend Modules]
The list confirms that the pdo_mysql
extension is missing. You need to install it to be able to communicate with the MySQL container you just created.
Installing and configuring additional extensions for PHP images is a common task so the base PHP Docker image comes with a rich set of helper scripts that make the process straightforward.
You can find these scripts at /usr/local/bin
inside the product-api
container:
docker exec product-api ls -Al /usr/local/bin
Output:
total 20560
-rwxrwxr-x 1 root root 122 Feb 16 21:27 docker-php-entrypoint
-rwxrwxr-x 1 root root 1449 Feb 16 21:27 docker-php-ext-configure
-rwxrwxr-x 1 root root 2669 Feb 16 21:27 docker-php-ext-enable
-rwxrwxr-x 1 root root 2963 Feb 16 21:27 docker-php-ext-install
-rwxrwxr-x 1 root root 587 Feb 16 21:22 docker-php-source
-rwxr-xr-x 1 root root 817 Feb 16 21:30 pear
-rwxr-xr-x 1 root root 838 Feb 16 21:30 peardev
-rwxr-xr-x 1 root root 751 Feb 16 21:30 pecl
lrwxrwxrwx 1 root root 9 Feb 16 21:30 phar -> phar.phar
-rwxr-xr-x 1 root root 15242 Feb 16 21:30 phar.phar
-rwxr-xr-x 1 root root 20989776 Feb 16 21:30 php
-rwxr-xr-x 1 root root 3024 Feb 16 21:30 php-config
-rwxr-xr-x 1 root root 4531 Feb 16 21:30 phpize
The one you want to use in particular for installing a PHP extension is called docker-php-ext-install
. It is invoked as follows:
docker exec product-api docker-php-ext-install pdo_mysql
But, besides for testing, there's no point in doing this at runtime. All required extensions should be part of the final image itself.
If you nevertheless decide to test this at runtime, don't forget that if you install a new PHP extension while the original php-fpm
master process is running, you'll need to force a reload of the PHP configuration afterward to ensure that the php-fpm
process picks up the new extension.
This can be done by running either:
docker restart product-api
or
docker exec product-api kill -USR2 1
Go ahead and update your Dockerfile
with the desired docker-php-ext-install
command:
Now rebuild the image:
docker build -t product-api:0.4.0 .
Stop the old container and replace it with a container using the latest image:
docker stop product-api
docker run -d --rm --name product-api --network product-api-network --env-file laravel.env product-api:0.4.0
Refresh the localhost:8080
web page:
That (almost) worked! The Product API is successfully communicating with the underlying database, and indeed, a database table seems to be missing, but this is purely an application error, not related to the Docker image itself.
Before fixing this application error in the next section, let's wrap the current discussion up by clarifying what docker-php-ext-install
really does behind the scenes.
First and foremost, every official PHP image includes the PHP source code compressed as an XZ archive. The archive is located at /usr/src/php.tar.xz
, which you can confirm by running the command below:
docker exec product-api ls -Alh /usr/src
Output
total 12M
-rw-r--r-- 1 root root 11.9M Feb 16 21:22 php.tar.xz
-rw-r--r-- 1 root root 833 Feb 16 21:22 php.tar.xz.asc
In PHP, extensions such as pdo_mysql
can be linked to the main php-fpm
binary executable, either statically or dynamically. Static linking means that the compiled extension is embedded into the php-fpm
binary itself (increasing its total size but slightly speeding up script execution times) while dynamic linking means that the php-fpm
executable loads the extension at runtime (after the php-fpm
master process starts).
When you invoke docker-php-ext-install
, it orchestrates other helper scripts to perform the actual installation. This happens in the following sequence:
docker-php-source
extracts the PHP source code fromphp.tar.xz
to/usr/php/src
. This exposes the source code of thepdo_mysql
extension at/usr/src/php/ext/pdo_mysql
inside the container.docker-php-ext-configure
prepares the PHP build system for compiling thepdo_mysql
extension as a shared library. This brings in all the Linux libraries required for the compilation process and generates aMakefile
inside the/usr/src/php/ext/pdo_mysql
folder.docker-php-ext-install
starts the compilation through the generatedMakefile
. The resulting output is apdo_mysql.so
file copied to the PHP extension directory reported byphp-config --extension-dir
(e.g.,/usr/local/lib/php/extensions/no-debug-non-zts-20230831
).- Finally,
docker-php-ext-enable
creates a new INI file in the directory reported byphp-config --ini-dir
(e.g.,/usr/local/etc/php/conf.d
) containing anextension=pdo_mysql.so
directive that informs thephp-fpm
master process to load this extension dynamically at runtime.
With all of this clarified, you can proceed to resolve the application error you encountered (regarding the missing products
table) in the next section.
Running database migrations
In the previous section, you encountered an error indicating that a database table was missing:
Laravel also suggested that you might have forgotten to run your database migrations. Indeed, the database you created earlier was never seeded with any data.
When running apps in production, it's always a good idea to exert strict control over your migration procedure. Because of that, executing database migrations should better be done outside the main container serving your application.
For smaller and non-critical applications, migrations can be executed through manual invocation using a separate container. For larger applications relying on automated deployment, the migration command may be defined as either an individual step in the CI/CD pipeline or an entirely separate CI job.
In Laravel, migrations are executed through the php artisan migrate
command. Your custom image already includes the artisan
CLI, so executing the migrations is as simple as launching a new container with the following command:
docker run -it --rm --name product-api-migrations --network product-api-network --env-file laravel.env product-api:0.4.0 php artisan migrate
The added -it
option launches the container in interactive mode, as Laravel will prompt you to confirm the migration since the application is running in production mode.
An interactive prompt appears, asking you to confirm the migration:
Select "Yes" and hit Enter:
The migration completes successfully in a couple of seconds, creating an empty products
table inside the products_api
MySQL database.
For the sake of testing, you can seed this table with some records by running:
docker run -it --rm --name product-api-migrations --network product-api-network --env-file laravel.env product-api:0.4.0 php artisan db:seed ProductSeeder
Please bear in mind, though, that seeding is an operation that should only be performed locally for testing. Never seed an actual production database with random data!
A prompt similar to the database migration prompt appears, requesting you to confirm the operation:
Select "Yes" and hit Enter once again:
Now go ahead and refresh localhost:8080
:
Congratulations! The application now loads up properly!
Reducing the image size
At this point, you have a fully working Docker image capable of running your application code in production.
However, before wrapping this article up, you can perform a small optimization to reduce the final size of your image. Remember that keeping the total size small leads to faster deployment times and lower resource usage.
Run the following command:
docker image ls product-api
You'll notice that the current size of your image is 165MB.
REPOSITORY TAG IMAGE ID CREATED SIZE
product-api 0.4.0 f248952dc850 2 days ago 165MB
product-api 0.3.0 aa2d310eeda2 3 days ago 165MB
product-api 0.2.0 931726fe3518 3 days ago 165MB
product-api 0.1.0 e1ad9dcffccc 3 days ago 83.6MB
Earlier, when installing the required Composer packages into the image, you did so by running composer install
. However, like all PHP frameworks, Laravel comes with many development dependencies that are useful while prototyping your application locally but aren't needed for production. You can easily save some storage space by removing these dependencies from your final image.
Go ahead and change your Dockerfile
as follows:
The --no-dev
option instructs Composer to skip installing development packages.
Rebuild your image and check its size:
docker build -t product-api:0.5.0 .
docker image ls product-api
The resulting image is 50MB smaller! A significant improvement.
REPOSITORY TAG IMAGE ID CREATED SIZE
product-api 0.5.0 50fea22206db 5 seconds ago 115MB
product-api 0.4.0 f248952dc850 2 days ago 165MB
product-api 0.3.0 aa2d310eeda2 3 days ago 165MB
product-api 0.2.0 931726fe3518 3 days ago 165MB
product-api 0.1.0 e1ad9dcffccc 3 days ago 83.6MB
Using the Docker image you created, the Product API can now be easily launched in a production environment as a Docker container.
Final thoughts
Congratulations on following through to the end of this article!
You've learned so much! From understanding what a Docker image is and how to choose one as a base for customization through comprehending important PHP internals and learning how to install PHP extensions to successfully putting together a Dockerfile
and building a fully functional Docker image that runs an actual PHP application. This was a lot of work, so well done!
Hopefully, all of this knowledge will be of good use in your future projects. Remember, practice makes perfect, so don't hesitate to experiment with different images and configurations, and to keep exploring and building on what you've learned to continue improving your PHP and Docker skills.
Stay tuned for the next part of this series, where you'll learn how to deploy and operate the Docker image you created in production.
Thanks for reading, and until next time!