Episode code: 001-03
Series: Building a Development Environment for Web Development
Date: 2026-05-13
Episode 3: Docker Compose
Description¶
In this video we are continuing building our development environment by introducing docker-compose.
docker-compose is an example of infrastructure as code; it enables us to encapsulate the whole project configuration in a single, easy-to-maintain file, describing services and how they interact.
Video¶
Instructions¶
In the last video we built a Dockerfile for the guest book web application.
This separated the application from the host machine, and we can easily move the application to another machine.
It also solves the it works on my machine problem. Because Docker images encapsulates their environment and dependencies, we are not dependent on the host machine to be configured in a particular way.
However, we also saw that it was error-prone and cumbersome to deal with the Docker containers individually, and having to pass their configuration as options every time we run them.
This is the problem we will address with docker-compose.
Services¶
docker-compose.yml is a YAML formatted file, that declaratively describes your project in a hierarchical structure.
The first top-level element we will take a look at is the services element.
In the file above, we declare two services: db for the database service, and app for the web application service.
Note
YAML files are hierarchical files, where the hierarchy is determined by indention.
This means that you have to be careful with indention.
Images¶
Let us go ahead and further describe our two services.
| docker-compose.yml | |
|---|---|
Each service specifies an image.
This is simply a Docker image, that docker-compose will use for running the service in a Docker container.
Note
In the example above we use the :latest version of the images.
This is potentially a problem, because changes in an image make break our application.
However, as long as you are in development, it is probably ok.
Make a ticket in your ticket system under the production milestone to freeze the image versions to address this issue.
Bind Mount Volumes¶
The db service maps a bind-mount volume for the dbfiles so it can read the provisioning file (line 6).
The left side of the : indicates the directory on the host machine, wheres the right side indicates the directory inside the Docker container.
That is, in the current directory on the host machine, the ./dbfiles directory is mapped to the /dbfiles inside the Docker container.
Bind mounts are used for ad-hoc mapping into the project directory. Using bind mounts, we can make changes to files and have the changes take effect on the Docker container without having to rebuild the Docker image.
The :ro configuration set the bind mount in read-only mode.
There is no reason the service should write to the bind mount, so it is considered best practice to limit the access to read-only.
Environment Variables¶
Finally, both services will read the env-${RTE} (Run Time Environment) environment variables into its environment (lines 7-8, 12-13).
The ${RTE} part is a placeholder, that will allow us to use different environment files by providing the RTE variable.
Specifically, we want to be able to change the behavior in development, test, and production.
Thus, we can set the RTE variable to dev, test, or prod.
This way, we do not have to maintain separate docker-compose.yml files, which would be a pain.
Named Volumes¶
You might recall from the last video that I mentioned that Docker images are ephemeral, that is, they are short-lived, and they do not have persistence.
This is a problem for our db database service; it should not start from scratch every time we restart the service.
This is where named volumes enters the picture.
| docker-compose.yml | |
|---|---|
In the listing above we introduce the guestbookdb volume (line 7).
Notice that this is different from the ./dbfiles bind mount, in that it simply declares a name (guestbookdb), whereas the ./dbfiles references the relative path to a directory.
Docker will determine a file location for the named volume, but you can find it in your host file system by running the docker volume inspect command.
Inside the Docker container we still have to specify a real directory path, in this case /var/lib/postgresql where PostgreSQL stores the database files.
Named volumes are also listed in the top-level volumes section (line 16-17).
Networks¶
In the last video we relied on the host machine network for communication between the database and the web application. This is not an ideal situation, especially when we put our project into production.
To address this problem we introduce Docker networks.
| docker-compose.yml | |
|---|---|
The db and app services are both connected to the dbnet network.
Only these two machines are connected to this network, so only the app service can communicate directly with the database service.
The app service is also connected to the webnet.
This network is for communicating with a reverse proxy, which we will get to next.
Separating the services this way improve security, and since it is all done in software, it is also completely free.
Nginx Reverse Proxy Service¶
The application service should not be directly exposed to the internet, for several reasons:
- it would not be very secure
- it would not be very flexible, in that we could only run one project on the host
- the application service would have to handle TLS certificates, which many application servers can not do
- it would be inefficient, as web application servers are not designed to handle caching and static content efficiently
So let us go ahead and implement the nginx service.
The nginx service makes use of the nginx:latest Docker image.
Unlike with other images, we would argue that we should always use the latest image so we have the latest security updates.
Nginx is very conservative with changes to the configuration API, so changes are few and far between.
We configure a bind mount for the configuration file (line 23-24).
The nginx server expects to find configuration files on the path /etc/nginx/conf.d, and again, we configure the bind mount read-only.
Next, we connect the nginx service to the webnet network in order for it to be able to communicate with the app service (lines 25-26).
Finally, we need to expose the ports for nginx to communicate with the world outside of Docker.
We set up port mappings for HTTP (line 28) and HTTPS (line 29) protocols on their default port numbers.
The :- syntax makes it possible to provide a default if the variable is not set.
In dev and test mode these must be overwritten in some cases.
docker-compose.yml does not offer any way to not set the second port mapping in dev and test mode, so in some cases we must set some dummy port number.
Nginx Configuration File¶
We must implement the configuration file needed by nginx.
| ./nginx/guestbook.conf | |
|---|---|
This configuration is as simple as can be.
We define the guestbook upstream for the app service (lines 1-3); we can refer to the name app because Docker provides the names of services on a Domain Name Service (DNS), simplifying things greatly.
In line 9 we forward all incoming requests to the guestbook upstream.
Finally, we listen on port 80 for in-coming traffic, matching the configuration in docker-compose.yml line 28.
This configuration is obviously not production-ready, but we will keep things simple for now and focus on dev mode.
Updated Environment Variables File¶
We need to make a few updates to our environment variables.
| env-dev | |
|---|---|
Notice we made a few changes.
We now make use of the WEB_HOST environment variable to run the web server listening on all addresses (0.0.0.0).
We can no longer use 127.0.0.1, as that will only be accessible inside the app Docker container.
The db service must also be accessible from outside its own container, so 127.0.0.1 must also change (lines 5 and 10).
We do not need to listen on all ports, so we make use of the db DNS name.
Running The Project¶
With that, we can finally go ahead and run the project.
This may or may not work, because there are two issues we have to deal with.
First, you might not be able to run a server on ports 80 and 443 without root privileges.
We do not want to run our service as root, so if you get error messages hinting at this problem, run the command like below, providing different port mappings.
nginx service to ports 4000 and 4001, that do not need root permissions.
Note
If you do not what to type these environment variables repeatedly, just set the variables in your shell: export HTTP_PORT=4000 etc.
Note
All port numbers under 1024 need root permission under Unix-like systems like Linux, macOS, BSD, etc.
Another issue is that the db service may or may not be ready when the app service try to connect to it, and the app service may or may not be ready when the nginx service try to connect to it.
We introduce depends_on directives, so that the app service waits for the db service, and the nginx service waits for the app service, forcing a startup order: db -> app -> nginx.
In most cases this will do the trick.
In some rare cases, this is still not enough.
The problem is that depends_on only wait for the service to start, but it may or may not be ready.
There are different ways to solve this problem; we will focus on an easy solution.
The code is somewhat self-explanatory: the db service will perform periodic health-checks using the pg_isready command that the PostgreSQL Docker image ships with (lines 12-17).
You will notice that docker-compose stalls a bit after starting the db service, due to this change.
The app service now waits for the db service to report healthy status, and then proceeds (lines 27-28).
Finally, in line 41, the nginx service is set to restart unless stopped.
These changes should solve any timing problems, but as mentioned, they are pretty rare.
Testing The Project¶
Time to test the project.
Make sure docker-compose up is running, and run the command below.
Inside the db shell run the command below to access the PostgreSQL server shell.
And run the provisioning script.
Exit running \q and exit, and you should be back to your host machine command line.
Finally, run the command to post a message.
Summary¶
We now have a project that documents and encapsulates the dependencies between the services, and it is easy to start, stop, and maintain.
In the next video we will look into automating various software quality assurance issues such as linting, syntax checking, testing, and building images. We will do this by introducing a Continuous Integration (CI) pipeline.
