Marc Wäckerlin
Für eine libertäre Gesellschaft

Tutorial: Docker-Compose Deployment

Oktober 1, 2024

Views: 8

After following the steps in Image from Dockerfile, where you created the Simple NestJS Project and the associated Dockerfile, now we will deploy this using Docker-Compose. This will allow us to manage multiple services, including our NestJS application and WordPress.

Docker Compose has an extensible YAML format, which is defined in the Compose file reference.

Simple Application Deployment

Start with the simple project and Dockerfile from the previous tutorial. Then in the same project path create a docker-compose.yaml file to build and deploy the NestJS image with the following content:

services:
  hello-world:
    build: .
    image: ${REPOSITORY}/hello-world:${TAG:-develop}
    ports:
      - "4000:3000"
  • services: declares all services in this deployment. For now, it is our hello-world service, which is the NestJS project we wrote in the last session
  • hello-world: in services is an arbitrary service name
  • build: tells Docker Compose to build the image from the current directory using the local Dockerfile
  • image: defines the image name in the form repository/image:tag. The definition here uses the variables ${REPOSITORY} and ${TAG:-develop}. REPOSITORY refers to a Docker Hub account like mwaeckerlin, or a private repository. The TAG allows to specify a version, here it defaults to develop if not set
  • ports: maps local port 4000 to internal container port 3000, allowing access to the application

As you see, you can use variables everywhere in the Docker Compose. Variables may have default values, as in the bash parameter expansion: ${VARIABLE_NAME:-default}. These variables may be set in the environment, or in a .env file, e.g. create a local file in the same project path named .env containing:

REPOSITORY=mwaeckerlin
TAG=latest

Instead of mwaeckerlin you may set your own Docker Hub repository name, or any repository elsewhere. If it is not in Docker Hub, then the repository name requires the full URL.

Build and Deploy the Project

Once your docker-compose.yaml and .env are set up, build the image (if docker compose does not work for you, try docker-compose, that’s the deprecated command; if it complains something about version, add version: '3.8' as the first line on top, that’s also deprecated syntax):

docker compose build

This builds the image. Last line you see if you copied the above .env file is: naming to docker.io/mwaeckerlin/hello-world:latest. As you see, the repository URL docker.io is implicit default.

After building, you can start all services in the file, option -d starts them detached in the background:

docker compose up -d

Now you have started your service, which listens on port 5000, so head your browser to http://localhost:5000 to get the output of your service.

Check the logs of your service with docker compose logs -tf, where -t adds a time stamp and -f follows the logs (continues logging output until the services stopped). In the logs, you see all of stdout and stderr.

You can now stop your service with docker compose stop, start again with docker compose start, or remove it with docker compose rm -vfs, where the options -v also removes anonymous (temporary) volumes, -f prevents asking to confirm and -s stops running processes, so removes all.

If you want to push the built image to the repository, simply call docker compose push.

The option --help is your friend, be it docker compose --help to see all commands or docker compose up --help to see all options of a specific command. Have fun!

Don’t forget to clean your system from time to time, e.g. with docker system prune. You may add it to a cron job.

Extended Docker-Compose with WordPress and MySQL

Next, we will expand the docker-compose.yaml to add WordPress, MySQL, and Traefik as a reverse proxy.

First we add three new variables to the `.env` file, a database name, a user name and a password for the wordpress database, so change your .env file to:

REPOSITORY=mwaeckerlin
TAG=latest
WORDPRESS_DB_NAME=wordpress
WORDPRESS_DB_USER=wordpress
WORDPRESS_DB_PASSWORD=S3crE7P@55w0rd

Of course in real life, you would change a more secure password, and you would never store a file containing the password anywhere in an unprotected location, such as a git repository, but you would rather use secrets.

Now you may change and extend the docker-compose.yaml file to:

services:

  traefik:
    image: traefik
    command:
      - "--log.level=DEBUG"
      - "--api.insecure=true"
      - "--providers.docker=true"
      - "--entrypoints.web.address=:80"
    ports:
      - "9000:80"
      - "9001:8080"
    networks:
      - hello-world
      - wordpress
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

  hello-world:
    build: .
    image: ${REPOSITORY}/hello-world:${TAG:-develop}
    labels:
      - "traefik.http.routers.hello-world.rule=Host(`localhost`) && PathPrefix(`/api`)"
      - "traefik.http.services.hello-world.loadbalancer.server.port=3000"
      - "traefik.http.middlewares.hello-world-strip-path.stripprefix.prefixes=/api"
      - "traefik.http.routers.hello-world.middlewares=hello-world-strip-path"
    networks:
      - hello-world

  wordpress:
    image: wordpress
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER:
      WORDPRESS_DB_PASSWORD:
      WORDPRESS_DB_NAME:
      WORDPRESS_CONFIG_EXTRA: |
        define('WP_SITEURL', 'http://localhost:9000/wordpress');
        define('WP_HOME', 'http://localhost:9000/wordpress');
    labels:
      - "traefik.http.routers.wordpress.rule=Host(`localhost`) && PathPrefix(`/wordpress`)"
      - "traefik.http.services.wordpress.loadbalancer.server.port=80"
      - "traefik.http.middlewares.wordpress-strip-path.stripprefix.prefixes=/wordpress"
      - "traefik.http.routers.wordpress.middlewares=wordpress-strip-path"
    networks:
      - wordpress
      - database
    depends_on:
      - db

  db:
    image: mysql
    environment:
      MYSQL_RANDOM_ROOT_PASSWORD: "yes"
      MYSQL_DATABASE: ${WORDPRESS_DB_NAME}
      MYSQL_USER: ${WORDPRESS_DB_USER}
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql
    networks:
      - database

networks:
  hello-world:
    driver_opts:
      encrypted: 1
  wordpress:
    driver_opts:
      encrypted: 1
  database:
    driver_opts:
      encrypted: 1

volumes:
  db_data:

Try it:

docker compose build
docker compose up

Then you may load:

There is no more port opened for hello-world, so http://localhost:4000 does not work anymore. This removal is optional, if you keep the port definition, you can access hello-world also on http://localhost:4000, in addition to http://localhost:9000/api.

Now to the details of the configuration: A simple WordPress installation on http://localhost:9000 with a MySQL database would just be:

services:

  wordpress:
    image: wordpress
    ports:
      - "9000:80"
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME:
      WORDPRESS_DB_USER:
      WORDPRESS_DB_PASSWORD:
    depends_on:
      - db

  db:
    image: mysql
    environment:
      MYSQL_RANDOM_ROOT_PASSWORD: "yes"
      MYSQL_DATABASE: ${WORDPRESS_DB_NAME}
      MYSQL_USER: ${WORDPRESS_DB_USER}
      MYSQL_PASSWORD: ${WORDPRESS_DB_PASSWORD}
    volumes:
      - db_data:/var/lib/mysql

volumes:
  db_data:

The WORDPRESS_CONFIG_EXTRA is required to place WordPress into /wordpress path.

Traefik needs the local docker socket /var/run/docker.sock to be mounted. This allows Trafik to scan other services for traefik related labels to setup the routing. So the labels starting with traefik. are used by Traefik.

This connects hello-world on the host name localhost to path /api, forwards to port 3000, where the process runs and strips /api from the path:

    labels:
      - "traefik.http.routers.hello-world.rule=Host(`localhost`) && PathPrefix(`/api`)"
      - "traefik.http.services.hello-world.loadbalancer.server.port=3000"
      - "traefik.http.middlewares.hello-world-strip-path.stripprefix.prefixes=/api"
      - "traefik.http.routers.hello-world.middlewares=hello-world-strip-path"

Same here for WordPress, this connects WordPress on the host name localhost to path /wordpress, forwards to port 80, where the process runs and strips /wordpress from the path:

    labels:
      - "traefik.http.routers.wordpress.rule=Host(`localhost`) && PathPrefix(`/wordpress`)"
      - "traefik.http.services.wordpress.loadbalancer.server.port=80"
      - "traefik.http.middlewares.wordpress-strip-path.stripprefix.prefixes=/wordpress"
      - "traefik.http.routers.wordpress.middlewares=wordpress-strip-path"

As you see, we now define three networks, all encrypted:

  • hello-world connects traefik with hello-world
  • wordpress connects traefik with wordpress
  • database connects wordpress with db

So hello-world can neither connect to wordpress not to db, these services are segregated, which is an important security feature.

The database stores data in a locally mounted volume, so the data even survives a deletion and recreation of the db container. Just don’t delete the volume:

    volumes:
      - db_data:/var/lib/mysql

If a variable is not defined, then it is set by an environment variable of the same name, such as in:

    environment:
      WORDPRESS_DB_NAME:
      WORDPRESS_DB_USER:
      WORDPRESS_DB_PASSWORD:

comments title