Skip to content
CitexNode
All guides

Docker and Docker Compose

Docker Engine with the Compose plugin from the official repository, a Compose example, and why Docker bypasses your firewall.

5 min read

Applies to Debian 12/13, Ubuntu 22.04/24.04, AlmaLinux 9 and Rocky Linux 9. We install Docker from Docker's official repository, not the older docker.io package from the distribution.

Debian

bash
apt remove -y docker.io docker-compose podman-docker containerd runc 2>/dev/null
apt install -y ca-certificates curl
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Ubuntu

Same as Debian, with ubuntu in the URLs:

bash
apt remove -y docker.io docker-compose podman-docker containerd runc 2>/dev/null
apt install -y ca-certificates curl
install -d -m 0755 /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" > /etc/apt/sources.list.d/docker.list
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

AlmaLinux and Rocky Linux

bash
dnf remove -y podman buildah runc 2>/dev/null
dnf install -y dnf-plugins-core
dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker

dnf asks about Docker's GPG key. The fingerprint must be 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35.

On Debian and Ubuntu, Docker starts on its own after installation.

Check that it worked

bash
docker version
docker compose version
docker run --rm hello-world

hello-world pulls a tiny image and prints Hello from Docker!.

A Compose example

A small web app with a database. Create a folder:

bash
mkdir -p /opt/app && cd /opt/app
nano compose.yaml
yaml
services:
  web:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro

  db:
    image: postgres:17
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - db-data:/var/lib/postgresql/data

secrets:
  db_password:
    file: ./db_password.txt

volumes:
  db-data:
bash
mkdir -p html && echo "Hello" > html/index.html
openssl rand -base64 24 > db_password.txt && chmod 600 db_password.txt
docker compose up -d

The important points:

  • 127.0.0.1:8080:80 publishes the web server on the server itself only. Nobody can reach it from outside. A reverse proxy such as Caddy or nginx on the host then forwards https://your-domain to it.
  • The database has no ports: at all. The web container still reaches it by the name db, because Compose puts both into a shared network.
  • restart: unless-stopped brings the containers back after a server reboot.

Useful commands in the folder:

bash
docker compose ps            # what's running
docker compose logs -f web   # live logs
docker compose pull          # fetch new images
docker compose up -d         # restart with new images
docker compose down          # stop and remove (volumes stay)

Test on the server:

bash
curl http://127.0.0.1:8080

Docker and the firewall

Docker writes its own rules into the kernel's packet filter. Connections to published ports go past the rules of ufw and firewalld. So ufw deny 8080 doesn't help when a container runs with -p 8080:80.

How to stay safe:

  1. Publish on 127.0.0.1 only, as in the example above: "127.0.0.1:8080:80". Whatever should be reachable from outside (usually just 80 and 443) goes through a reverse proxy on the host or a dedicated proxy container.

  2. Never publish databases. Containers in the same Compose network don't need ports:.

  3. If a port must be public, but only for certain addresses: rules in the DOCKER-USER chain. Docker evaluates them before its own rules. Example, port 5432 only for 198.51.100.7 (look up the interface name with ip -br link):

    bash
    iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 ! -s 198.51.100.7 -j DROP

    Such rules are gone after a reboot unless you persist them, and they only cover IPv4. That's why option 1 is almost always the better way.

Common errors

MessageCause and fix
permission denied while trying to connect to the Docker daemon socketNot root and not in the docker group. Use sudo docker … or add the group and log in again.
docker: 'compose' is not a docker commanddocker-compose-plugin is missing. The old hyphenated docker-compose command is outdated.
Bind for 0.0.0.0:80 failed: port is already allocatedSomething else uses the port. ss -tlnp shows what.
Conflict with podman or runc on AlmaLinux/RockyRun dnf remove podman buildah runc first, as above.
no space left on deviceOld images and build leftovers: docker system df, clean up with docker system prune.

What next