Docker For Local Web Development, Part 1 - A Basic LEMP Stack
Docker For Local Web Development, Part 1 - A Basic LEMP Stack
In this post
In this series
In this post
The first steps
Identifying the necessary containers
Docker Compose
Nginx
PHP
MySQL
phpMyAdmin
Domain name
Environment variables
Commands summary and cleaning up your environment
Conclusion
Once both requirements are covered, you can either get the final
result from the repository and follow this tutorial, or start from
scratch and compare your code to the repository's whenever you
get stuck. The latter is my recommended approach for Docker
beginners, as the various concepts are more likely to stick if you
write the code yourself.
Note that this post is quite dense because of the large number of
notions being introduced. I assume no prior knowledge of Docker
and I try not to leave any detail unexplained. If you are a complete
beginner, make sure you have some time ahead of you and grab
yourself a hot drink: we're taking the scenic route.
L is for Linux;
E is for Nginx;
M is for MySQL;
P is for PHP.
Linux is the operating system Docker runs on, so that leaves us
with Nginx, MySQL and PHP. For convenience, we will also add
phpMyAdmin into the mix. As a result, we now need the following
containers:
Docker Compose
Docker Desktop comes with a tool called Docker Compose that
allows you to define and run multi-container Docker applications (if
your system runs on Linux, you will need to install it separately).
Don't worry if you feel a little confused; by the end of this post it will
all make sense.
Nginx
The YAML configuration file will actually be our starting point: open
your favourite text editor and add a new docker-compose.yml file
to a directory of your choice on your local machine (your computer),
with the following content:
1 version: '3.8'
2
3 # Services
4 services:
5
6 # Nginx Service
7 nginx:
8 image: nginx:1.21
9 ports:
10 - 80:80
The version key at the top of the file indicates the version of
Docker Compose we intend to use (3.8 is the latest version at the
time of writing).
You will probably notice that all images have a latest tag
corresponding to the most up-to-date version of the image.
While it might be tempting to use it, you don't know how the
image will evolve in the future – it is very likely that breaking
changes will be introduced sooner or later. The same way you
do a version freeze for an application's dependencies (via
composer.lock for PHP or requirements.txt in Python, for
example), using a specific version tag ensures your Docker
setup won't break due to unforeseen changes.
$ docker compose up -d
$ docker compose ps
PHP
By the end of this section, we will have Nginx serving a simple
index.php file via PHP-FPM, which is the most widely used
process manager for PHP.
1 version: '3.8'
2
3 # Services
4 services:
5
6 # Nginx Service
7 nginx:
8 image: nginx:1.21
9 ports:
10 - 80:80
11 volumes:
12 - ./src:/var/www/php
13 - ./.docker/nginx/conf.d:/etc/nginx/conf.d
14 depends_on:
15 - php
16
17 # PHP Service
18 php:
19 image: php:8.1-fpm
20 working_dir: /var/www/php
21 volumes:
22 - ./src:/var/www/php
A few things going on here: let's forget about the Nginx service for a
moment, and focus on the new PHP service instead. We start from
the php:8.1-fpm image, corresponding to the tag 8.1-fpm of
PHP's official image, featuring version 8.1 and PHP-FPM. Let's skip
working_dir for now, and have a look at volumes . This section
allows us to define volumes (basically, directories or single files)
that we want to mount onto the container. This essentially means
we can map local directories and files to directories and files on the
container; in our case, we want Docker Compose to mount the
src folder as the container's /var/www/php folder.
What's in the src/ folder? Nothing yet, but that's where we are
going to place our application code. Once it is mounted onto the
container, any change we make to our code will be immediately
available, without the need to restart the container.
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset="UTF-8">
5 <title>Hello there</title>
6 <style>
7 .center {
8 display: block;
9 margin-left: auto;
10 margin-right: auto;
11 width: 50%;
12 }
13 </style>
14 </head>
15 <body>
16 <img src="https://tech.osteel.me/images/2020/03/04/hello.gif"
17 </body>
18 </html>
It only contains a little bit of HTML and CSS, but all we need for
now is to make sure PHP files are correctly served.
1 server {
2 listen 80 default_server;
3 listen [::]:80 default_server;
4 root /var/www/php;
5 index index.php;
6
7 location ~* \.php$ {
8 fastcgi_pass php:9000;
9 include fastcgi_params;
10 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name
11 fastcgi_param SCRIPT_NAME $fastcgi_script_name;
12 }
13 }
Note
A word on networks
1 depends_on:
2 - php
Your directory and file structure should now look similar to this:
docker-tutorial/
├── .docker/
│ └── nginx/
│ └── conf.d/
│ └── php.conf
├── src/
│ └── index.php
└── docker-compose.yml
We are ready for another test. Go back to your terminal and run the
same command again (this time, the PHP image will be
downloaded):
$ docker compose up -d
If you run docker compose ps you will observe that you now have
two containers running: nginx_1 and php_1 .
Before we move on to the next section, let me show you one last
trick. Go back to your terminal and run the following command:
Wait for a few logs to display, and hit the return key a few times to
add some empty lines. Refresh localhost again and take another
look at your terminal, which should have printed some new lines:
MySQL
The last key component of our LEMP stack is MySQL. Let's update
docker-compose.yml again:
1 version: '3.8'
2
3 # Services
4 services:
5
6 # Nginx Service
7 nginx:
8 image: nginx:1.21
9 ports:
10 - 80:80
11 volumes:
12 - ./src:/var/www/php
13 - ./.docker/nginx/conf.d:/etc/nginx/conf.d
14 depends_on:
15 - php
16
17 # PHP Service
18 php:
19 build: ./.docker/php
20 working_dir: /var/www/php
21 volumes:
22 - ./src:/var/www/php
23 depends_on:
24 mysql:
25 condition: service_healthy
26
27 # MySQL Service
28 mysql:
29 image: mysql/mysql-server:8.0
30 environment:
31 MYSQL_ROOT_PASSWORD: root
32 MYSQL_ROOT_HOST: "%"
33 MYSQL_DATABASE: demo
34 volumes:
35 - ./.docker/mysql/my.cnf:/etc/mysql/conf.d/my.cnf
36 - mysqldata:/var/lib/mysql
37 healthcheck:
38 test: mysqladmin ping -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PAS
39 interval: 5s
40 retries: 10
41
42 # Volumes
43 volumes:
44
45 mysqldata:
The Nginx service is still the same, but the PHP one was slightly
updated. We are already familiar with depends_on : this time, we
indicate that the new MySQL service should be started before PHP.
The other difference is the presence of the condition option; but
before I explain it all, let's take a look at the new build section of
the PHP service, which seemingly replaced the image one.
Instead of using the official PHP image as is, we tell Docker
Compose to use the Dockerfile from .docker/php to build a new
image.
1 FROM php:8.1-fpm
2
3 RUN docker-php-ext-install pdo_mysql
A lot more can be done with a Dockerfile, and while this is a very
basic example some more advanced use cases will be covered in
subsequent articles.
For the time being, let's update index.php to leverage the new
extension:
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset="UTF-8">
5 <title>Hello there</title>
6 <style>
7 body {
8 font-family: "Arial", sans-serif;
9 font-size: larger;
10 }
11
12 .center {
13 display: block;
14 margin-left: auto;
15 margin-right: auto;
16 width: 50%;
17 }
18 </style>
19 </head>
20 <body>
21 <img src="https://tech.osteel.me/images/2020/03/04/hello.gif"
22 <?php
23 $connection = new PDO('mysql:host=mysql;dbname=demo;charset=utf8', '
24 $query = $connection->query("SELECT TABLE_NAME FROM information
25 $tables = $query->fetchAll(PDO::FETCH_COLUMN);
26
27 if (empty($tables)) {
28 echo '<p class="center">There are no tables in database <code>de
29 } else {
30 echo '<p class="center">Database <code>demo</code> contains the
31 echo '<ul class="center">';
32 foreach ($tables as $table) {
33 echo "<li>{$table}</li>";
34 }
35 echo '</ul>';
36 }
37 ?>
38 </body>
39 </html>
1 [mysqld]
2 collation-server = utf8mb4_unicode_ci
3 character-set-server = utf8mb4
default-authentication-plugin = mysql_native_password
The second volume looks a bit different than what we have seen so
far: instead of pointing to a local folder, it refers to a named volume
defined in a whole new volumes section which sits at the same
level as services :
1 # Volumes
2 volumes:
3
4 mysqldata:
We need such a volume because without it, every time the mysql
service container is destroyed the database is destroyed with it. To
make it persistent, we basically tell the MySQL container to use the
mysqldata volume to store the data locally, local being the
default driver (just like networks, volumes come with various drivers
and options which you can learn about here). As a result, a local
directory is mounted onto the container, the difference being that
instead of specifying which one, we let Docker Compose pick a
location.
This is what these lines in the PHP service description were about:
1 depends_on:
2 mysql:
3 condition: service_healthy
We now have Nginx serving PHP files that can connect to a MySQL
database, meaning our LEMP stack is pretty much complete. The
next steps are about improving our setup, starting with seeing how
we can interact with the database in a user-friendly way.
phpMyAdmin
When it comes to dealing with a MySQL database, phpMyAdmin
remains a popular choice; conveniently, they provide a Docker
image which is pretty straightforward to set up.
If you are used to some other tool like Sequel Ace or MySQL
Workbench, you can simply update the MySQL configuration in
docker-compose.yml and add a ports section mapping
your local machine's port 3306 to the container's:
...
ports:
- 3306:3306
...
1 # PhpMyAdmin Service
2 phpmyadmin:
3 image: phpmyadmin/phpmyadmin:5
4 ports:
5 - 8080:80
6 environment:
7 PMA_HOST: mysql
8 depends_on:
9 mysql:
10 condition: service_healthy
Domain name
We have come a long way already and all that's left for today
mostly boils down to polishing up our setup. While accessing
localhost is functional, it is not particularly user friendly.
1 server {
2 listen 80;
3 listen [::]:80;
4 server_name php.test;
5 root /var/www/php;
6 index index.php;
7
8 location ~* \.php$ {
9 fastcgi_pass php:9000;
10 include fastcgi_params;
11 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name
12 fastcgi_param SCRIPT_NAME $fastcgi_script_name;
13 }
14 }
Add the following line to your hosts file and save it:
127.0.0.1 php.test
Environment variables
We are almost there, folks! The last thing I want to show you today
is how to set environment variables for the whole Docker Compose
project, rather than for a specific service like we have been doing
so far (using the environment section in docker-compose.yml ).
$ docker compose ps
COMPOSE_PROJECT_NAME=demo
Here is what the final directory and file structure should look like:
docker-tutorial/
├── .docker/
│ ├── mysql/
│ │ └── my.cnf
│ ├── nginx/
│ │ └── conf.d/
│ │ └── php.conf
│ └── php/
│ └── Dockerfile
├── src/
│ └── index.php
├── .env
├── .env.example
├── .gitignore
└── docker-compose.yml
$ docker compose up -d
Conclusion
Here is a summary of what we have covered today:
In the next part of this series, we will see how to choose and shrink
the size of our images. Subscribe to email alerts below so you don't
miss it, or follow me on Twitter where I will share my posts as soon
as they are published.
Enjoying the content?