Skip to content

Cookie settings

Choose which kinds of cookies you allow. You can change your choice at any time via Cookie settings at the bottom of every page. Privacy statement

Necessary

Always on

Needed to log in, keep your cart and remember your choices. These are always on.

Which cookies?
  • weboke_sessie, weboke_klant, weboke_remember: your session and staying logged in
  • weboke_basket: your cart
  • wkBtwIncl: showing prices with or without VAT
  • wok_cookies: your cookie choice, 12 months

Measures which ad leads to a visit or order, so we do not pay for ads that do nothing.

Which cookies?
  • Google Ads via Google Tag Manager (_gcl_au, _gcl_aw), up to 90 days
  • OpenAI Ads, ads in ChatGPT
  • wok_aff: which partner referred you to us (affiliate programme), 60 days

4.6

Country and language

Each country has its own site, with prices and VAT for that country.

View

Module 9 · 9.3

English

Docker compose and the firewall

Module 9: Docker and docker compose · Reading time: 12 minutes

Learning objective: After this lesson you can describe services in a compose file and start, update and stop them. You can also explain why ports published by Docker bypass ufw and how to prevent that.

Key concepts: compose.yaml, services, image, ports, volumes, environment, .env, restart, networks, depends_on, healthcheck, docker compose up -d, pull, logs, down, obsolete version:, Docker and ufw, DOCKER-USER, publishing on 127.0.0.1, secrets, docker.sock

Why compose?

With docker run (lesson 9.2) you start a single container with a long line of options. A real application often consists of several containers: a program, a database, a reverse proxy. If you have to remember those lines and type them in again after a reboot or update, sooner or later something goes wrong.

Docker Compose describes all the containers of an application in one file: compose.yaml. You put that file in its own folder, for example /srv/status/compose.yaml, and manage everything with docker compose. The file is also your documentation: what is running, which image, which ports, where the data lives. Include that folder in your backup (lesson 8.1) and you can restore the application on a new server in a few minutes.

The plugin is called docker compose (with a space, version 2). The old standalone program docker-compose (with a hyphen) has been replaced; you will still see it in old guides. The version: "3.8" field at the top of old files is also obsolete: Compose always uses the latest schema and only gives a warning for version:.

Reading a compose file

An example with a program and a PostgreSQL database:

# /srv/notities/compose.yaml
services:
  app:
    image: ghcr.io/example/notities:1.4
    restart: unless-stopped
    ports:
      - "127.0.0.1:8081:8080"
    environment:
      DB_HOST: db
      DB_USER: notities
      DB_PASSWORD: ${DB_PASSWORD}
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_USER: notities
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notities"]
      interval: 10s
      retries: 5

volumes:
  db-data:

Step by step:

  • services: each service becomes a container. The name (app, db) is also the hostname the containers use to find each other. Compose creates a separate network for the application, so app connects to the database at db:5432.
  • image: always with a version tag (lesson 9.1).
  • restart: what Docker does when the container stops or the server reboots. unless-stopped starts it again, unless you stopped it on purpose. Without a restart policy, your application will not come back after a reboot (lesson 4.2).
  • ports: only the app publishes a port, and only on 127.0.0.1. The database publishes nothing: the app reaches it through the internal network. A database with ports: - "5432:5432" is one of the most common mistakes (see below).
  • environment: settings for the program. The password is not written out literally, but appears as ${DB_PASSWORD}.
  • volumes: the named volume db-data keeps the database when the container is replaced.
  • depends_on with healthcheck: the app only starts when the database is really ready, not just started.

Secrets in a .env file

Compose reads variables such as ${DB_PASSWORD} from a .env file in the same folder:

# /srv/notities/.env
DB_PASSWORD=Kies-hier-een-lang-willekeurig-wachtwoord
anna@web1:/srv/notities$ sudo chmod 600 .env

This keeps the password out of the compose file, which you might store in Git or paste on a forum when asking for help. Add .env to .gitignore. Generate the password with a password manager or with openssl rand -base64 24.

Everyday use

You run all commands in the folder that contains the compose file:

anna@web1:/srv/notities$ sudo docker compose config     # check the file, variables filled in
anna@web1:/srv/notities$ sudo docker compose up -d      # start everything (or update to match the file)
anna@web1:/srv/notities$ sudo docker compose ps         # status per service
anna@web1:/srv/notities$ sudo docker compose logs -f app
anna@web1:/srv/notities$ sudo docker compose pull       # pull new images
anna@web1:/srv/notities$ sudo docker compose up -d      # replace containers with the new images
anna@web1:/srv/notities$ sudo docker compose down       # stop and remove containers

down leaves your volumes alone. down -v also removes the application's volumes, and with them your database. Only type that if you really want to start from scratch.

docker compose config is the equivalent of nginx -t and sshd -t: it shows how Compose interprets your file, with all variables filled in, before you start anything. Be careful not to paste that output anywhere, because it contains the passwords.

Docker bypasses ufw

Now for the most important security point of this module. In lesson 5.2 you set ufw to "deny everything, except SSH, 80 and 443". You would expect a container with ports: - "5432:5432" to be unreachable. It is not.

The Docker documentation says it plainly: Docker and ufw use firewall rules in ways that are incompatible with each other. When you publish a port, Docker adds its own rules to the kernel firewall. Those rules forward the traffic to the container (address translation, DNAT) before the ufw rules are applied. So the packet goes around ufw. ufw status shows nothing unusual, while the port is reachable from the entire internet. The same applies to firewalld.

On top of that, without an address Docker publishes on 0.0.0.0 and [::], so on both IPv4 and IPv6 (lesson 9.1).

Three ways to do it right, from simple to complex:

  1. Do not publish anything that does not need to be public. Databases, caches and internal services get no ports:. Containers in the same compose application reach each other through the internal network.
  2. Publish on loopback. "127.0.0.1:8081:8080". Then only the server itself can reach it, and you put a reverse proxy (nginx or Caddy, module 7) in front of it that listens on 443. You can also make this Docker's default: a daemon setting makes all published ports without an address land on 127.0.0.1.
  3. Rules in the DOCKER-USER chain. Docker creates an empty DOCKER-USER chain that is processed before its own rules. You can add your own rules there, for example "only this IP address may reach the published ports". That is advanced work with iptables or nftables, and easy to get wrong. For most small servers, 1 and 2 are enough.

Always check from the outside, as in lesson 5.1: nc -vz -w 3 <ip> 5432 from your laptop, or the course check in exercise Z5.1 and Z10.4.

Two more things not to do

  • Do not mount /var/run/docker.sock into a container unless you know exactly why. A container with access to the Docker socket can start containers itself, including one with the host's entire disk (lesson 9.2). Some tools (dashboards, automatic updaters) ask for it; that effectively gives root to that program.
  • No privileged: true or network_mode: host just because a guide says so. Both remove most of the container's isolation. Only use them if the developer explains why they are needed.

Summary

  • A compose file (compose.yaml) describes all the containers of an application; it doubles as documentation and belongs in your backup.
  • Use version tags, a restart policy, named volumes, a healthcheck where possible, and secrets in a .env with permissions 600.
  • docker compose config, up -d, ps, logs, pull, down; down -v also removes the volumes.
  • Published ports bypass ufw and firewalld. Only publish what needs to be public, and otherwise publish on 127.0.0.1 with a reverse proxy in front.
  • No docker.sock, privileged or network_mode: host without a good reason.

Quiz

1. ufw is set to "deny everything except 22, 80 and 443". Your compose file has ports: - "5432:5432" for the database. Can the database be reached from the internet?



2. How does the app container reach the database in the same compose application if the database does not publish a port?



3. After a server reboot, your containers are no longer running. What is probably missing from the compose file?



4. What is the difference between docker compose down and docker compose down -v?



5. A dashboard program asks for - /var/run/docker.sock:/var/run/docker.sock in its guide. What does that mean for security?



Exercise

Exercise Z9.3: Reviewing a compose file. The example file compose-nakijken.txt contains (as a text file) the compose file of a small web shop with an app, a database, a cache and an admin tool. It contains six mistakes covered in this lesson or in lessons 9.1 and 9.2. For each numbered block of lines, indicate whether it is correct or wrong, and for each mistake choose the right fix. The program compares your answers with the fixed key. If you have a compose file of your own, check it against the same points afterwards.

Exercise Z9.3: Reviewing a compose file

File for this exercise: compose-nakijken.txt





















What you enter and what we find isn't stored. Your browser remembers your answers and whether you've passed the exercise. If you're signed in, we only save that you got the exercise (almost) right in My courses; you can delete that there too.

Further reading

One moment…

Set up your domains

For each domain, arrange the nameservers, the link to your hosting and who becomes the owner.

Loading your domains…