Skip to content

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

Thumbnail

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.

docker-compose.yml
services:

    db:

    app:

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
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
        env_file:
            - "env-${RTE}"

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"

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
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
            - guestbookdb:/var/lib/postgresql
        env_file:
            - "env-${RTE}"

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"

volumes:
    guestbookdb:

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
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
            - guestbookdb:/var/lib/postgresql
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet
            - webnet

volumes:
    guestbookdb:

networks:
    dbnet:
    webnet:

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.

docker-compose.yml
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
            - guestbookdb:/var/lib/postgresql
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet
            - webnet

    nginx:
        image: nginx:latest
        volumes:
            - ./nginx:/etc/nginx/conf.d:ro
        networks:
            - webnet
        ports:
            - "127.0.0.1:${HTTP_PORT:-80}:80"
            - "127.0.0.1:${HTTPS_PORT:-443}:443"

volumes:
    guestbookdb:

networks:
    dbnet:
    webnet:

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
upstream guestbook {
    server app:3000;
}

server {
    listen 80;

    location / {
        proxy_pass http://guestbook;
    }
}

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
RTE=dev

WEB_HOST=0.0.0.0

POSTGRES_HOST=db
POSTGRES_DB=pgdb
POSTGRES_USER=pguser
POSTGRES_PASSWORD=pgpassword

PGHOST=db
PGPORT=5432
PGDATABASE=pgdb
PGUSER=pguser
PGPASSWORD=pgpassword

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.

Run the project using `docker-compose`
RTE=dev docker-compose up

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.

Run the project using `docker-compose`
RTE=dev HTTP_PORT=4000 HTTPS_PORT=4001 docker-compose up
This will bind the 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.

docker-compose.yml
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
            - guestbookdb:/var/lib/postgresql
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet
            - webnet
        depends_on:
            - db

    nginx:
        image: nginx:latest
        volumes:
            - ./nginx:/etc/nginx/conf.d:ro
        networks:
            - webnet
        ports:
            - "127.0.0.1:${HTTP_PORT:-80}:80"
            - "127.0.0.1:${HTTPS_PORT:-443}:443"
        depends_on:
            - app

volumes:
    guestbookdb:

networks:
    dbnet:
    webnet:

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.

docker-compose.yml
services:

    db:
        image: postgres:latest
        volumes:
            - ./dbfiles:/dbfiles:ro
            - guestbookdb:/var/lib/postgresql
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet
        healthcheck:
              test: ["CMD-SHELL", "pg_isready"]
              interval: 5s
              timeout: 5s
              retries: 5
              start_period: 30s

    app:
        image: guestbook:latest
        env_file:
            - "env-${RTE}"
        networks:
            - dbnet
            - webnet
        depends_on:
            db:
                condition: service_healthy

    nginx:
        image: nginx:latest
        volumes:
            - ./nginx:/etc/nginx/conf.d:ro
        networks:
            - webnet
        ports:
            - "127.0.0.1:${HTTP_PORT:-80}:80"
            - "127.0.0.1:${HTTPS_PORT:-443}:443"
        depends_on:
            - app
        restart: unless-stopped

volumes:
    guestbookdb:

networks:
    dbnet:
    webnet:

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.

Get a shell inside the `db` service
RTE=dev docker-compose exec -it db sh

Inside the db shell run the command below to access the PostgreSQL server shell.

Log into the PostgreSQL server
psql -U pguser -d pgdb

And run the provisioning script.

Run provisioning script
\i dbfiles/provisioning.sql

Exit running \q and exit, and you should be back to your host machine command line.

Finally, run the command to post a message.

Create a guest book entry
curl -X POST -d "tx=hello%20there&host=somehost" 127.0.0.1:3000

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.