What you will set up
Docker runs software in containers: each app comes as an image with everything it needs, and runs apart from the rest of the server. Docker Compose starts a set of containers from one file. Many projects ship their app as a Docker image with a compose.yaml, and tools such as Coolify, Dokploy and Pterodactyl need Docker underneath.
Our own app guides use rootless Podman instead, where every app runs under its own user without root. If you only want to run containers, the Podman guide is the alternative. This guide is for when you need Docker itself.
Docker Engine is developed by Docker, Inc., a company in Palo Alto, California, USA, together with the open-source Moby project. The Engine, the docker command and Compose are open source under the Apache License 2.0. Docker does not contact anything on its own: on our test server it looked up no names except when it pulled an image. Images come from Docker Hub by default, run by Docker, Inc. in the USA, which sees the server's IP address and the images it pulls; the layers themselves are served through Amazon CloudFront. Docker's privacy policy says data may be processed in the USA. Without an account, Docker Hub allows 100 pulls in six hours for each IPv4 address or IPv6 /64. To avoid Docker Hub, use images from another registry by their full name, such as ghcr.io/... or quay.io/....
Every step below was run on a fresh Melonslab VC-P Alloy (2 vCPU, 8 GB) with Debian 13:
- Docker 29.8.2 and Compose 5.5.1 installed from Docker's repository in about 20 seconds.
- With ufw on and no rule for it, a container port published on all addresses answered from the internet over IPv4: 32 of 40 test locations around the world connected. A systemd service that adds rules to Docker's own chain closed it over IPv4 and IPv6.
- By default, containers had no IPv6. On a network with IPv6 switched on, they reached IPv6 sites, and a published port saw the visitor's real IPv6 address.
- A first app with Compose, published on
127.0.0.1only, answered behind Caddy with a Let's Encrypt certificate, and saw each visitor's real address, over IPv4 and IPv6. - Logs from a container that wrote 213 MB stayed at 4.6 MB on disk.
- Docker updated itself with the automatic updates, and the app's container kept running.
- Everything came back by itself after a reboot.
Docker itself, dockerd and containerd, used about 160 MB of memory. With Caddy and two small containers, the whole server used about 540 MB.
Before you start
You need:
- a Melonslab server with Debian 13. Docker also supports Debian 12;
- SSH keys, automatic security updates and ufw, set up as in steps 1 to 4 of the security guide, with ports 22, 80 and 443 open;
- for step 5, a name for the app, such as
app.example.com, with an A and an AAAA record pointing at the server.
The examples use 203.0.113.10 and 2001:db8::10 for the server's addresses and 198.51.100.7 for your own. Replace them with your own throughout.
1. Install Docker from Docker's repository
Debian has its own docker.io package, but it is older: version 26.1.5 on Debian 13, against 29.8.2 from Docker. These are the steps from Docker's documentation. On a fresh server, none of the packages it says to remove first are installed. As root:
apt update
apt install -y ca-certificates curl
install -m 0755 -d /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
Add the repository:
tee /etc/apt/sources.list.d/docker.sources <<EOF
Types: deb
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
apt update
Install Docker, Buildx and the Compose plugin:
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Docker starts straight away, and starts at every boot. Check the versions:
docker version --format '{{.Server.Version}}'
docker compose version
29.8.2
Docker Compose version v5.5.1
Run a test container:
docker run --rm hello-world
It downloads a small image and prints Hello from Docker!.
2. See Docker open a port past ufw
ufw only allows ports 22, 80 and 443. Start a container that publishes port 8080:
docker run -d --name test -p 8080:80 traefik/whoami
From your own computer:
curl http://203.0.113.10:8080
It answers, with your address on the RemoteAddr line. The port is open to the whole internet, although ufw has no rule for it and ufw status does not show it. Docker forwards published ports to the container in its own firewall rules, before ufw's rules are reached. Every port in a ports: list, or after -p, is public unless you limit it. Docker's documentation warns about this too.
Over IPv6, this container's port did not answer: it is on Docker's default network, which has no IPv6, so Docker passes IPv6 connections through a helper program on the server, and ufw blocks those like any other program. On a network with IPv6, as in step 4, the port is open over IPv6 as well.
3. Close published ports
There are two ways, and you will usually use both.
Publish on 127.0.0.1 when a web server sits in front. A port written as 127.0.0.1:8082:80 only listens on the server itself, over IPv4 and IPv6, so nothing outside can reach it. Caddy, on the same server, can. Step 5 does this.
Drop the ports that must stay published on all addresses. Some apps publish a port you cannot change, such as a dashboard on 8000. Docker leaves one chain of its rules for you, DOCKER-USER, which is checked before its own. Create /etc/systemd/system/docker-ports.service, with the ports to close in the for p in list:
[Unit]
Description=Keep Docker ports off the internet
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c "for t in iptables ip6tables; do for p in 8080; do $t -C DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport $p -j DROP 2>/dev/null || $t -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport $p -j DROP; done; done"
[Install]
WantedBy=multi-user.target
The rules match the port the visitor connected to, --ctorigdstport, and not the port inside the container, which is what Docker's own rules would otherwise see. -i eth0 limits them to traffic from the internet, so the server itself and other containers can still connect. Start it:
systemctl daemon-reload
systemctl enable --now docker-ports.service
iptables -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 -j DROP
ip6tables -S DOCKER-USER shows the same rule for IPv6. Try curl http://203.0.113.10:8080 from your own computer again: it now waits until it gives up. The rules stay when Docker restarts, and the service adds them again after a reboot. To add a port later, add it to the list, run systemctl daemon-reload and systemctl restart docker-ports.service.
Remove the test container:
docker rm -f test
4. Give containers IPv6
Docker's default network has no IPv6. Containers on it cannot reach IPv6 addresses at all:
docker run --rm alpine wget -qO- -T5 http://ipv6.icanhazip.com
wget: can't connect to remote host: Network unreachable
Networks you create can have IPv6. Docker then gives the network its own private IPv6 range, starting with fd, and translates the containers' addresses to the server's own when they connect out, the same way it does for IPv4. Docker also switches on IPv6 forwarding on the server by itself. Try it with a network made by hand:
docker network create --ipv6 test6
docker run --rm --network test6 alpine wget -qO- -T5 http://ipv6.icanhazip.com
docker network rm test6
It prints the server's IPv6 address, 2001:db8::10. In Compose, switch IPv6 on for the app's network in compose.yaml:
networks:
default:
enable_ipv6: true
A port published on all addresses from such a network is open over IPv6 too, past ufw, and the container sees the visitor's real IPv6 address. The rules from step 3 close it over IPv6 as well, which we checked with an IPv6 port scan from outside.
You do not need IPv6 on the network for an app behind Caddy: Caddy answers visitors over both IPv4 and IPv6, and passes their address on, as the next step shows.
5. Run a first app with Compose
The app here is traefik/whoami, a small web server that shows the request it got. Each app gets a directory with its compose.yaml. Create /opt/whoami/compose.yaml:
services:
whoami:
image: traefik/whoami:v1.11.0
restart: unless-stopped
ports:
- "127.0.0.1:8082:80"
Pin a version in image:, as here, so the app only changes when you change the file. restart: unless-stopped starts the container again after a crash or a reboot. Start it:
cd /opt/whoami
docker compose up -d
docker compose ps
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
whoami-whoami-1 traefik/whoami:v1.11.0 "/whoami" whoami 1 second ago Up Less than a second 127.0.0.1:8082->80/tcp
Now put Caddy in front, from Debian's own package. It gets the certificate from Let's Encrypt and renews it:
apt install -y caddy
Replace /etc/caddy/Caddyfile with:
{
email you@example.com
}
app.example.com {
reverse_proxy 127.0.0.1:8082
}
The email address is where Let's Encrypt writes if something is wrong with the certificate. Check the file and load it:
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy
Within a minute, https://app.example.com answers, and http:// redirects to it. The page shows what the app sees:
RemoteAddr: 172.18.0.1:35068
X-Forwarded-For: 198.51.100.7
X-Forwarded-Proto: https
RemoteAddr is Docker's address for the server, because Caddy connects from there. The visitor's own address is in X-Forwarded-For, over IPv4 and IPv6 alike: in our test, the first IPv6 visitor was a scanner, minutes after the certificate was issued. Apps that should log visitors or limit login attempts must be told to trust that header from the proxy; each app's documentation says how.
Caddy's log shows the warning no OCSP stapling for [app.example.com] after the certificate is issued. It is harmless: Let's Encrypt no longer runs that service.
What to back up for an app like this: its directory under /opt, and its data. Data in named volumes is under /var/lib/docker/volumes/.
6. Rotate logs and keep containers running through restarts
Docker keeps everything a container prints, by default in a file that grows without limit. Create /etc/docker/daemon.json:
{
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
localstores logs compressed and keeps at most three files of 10 MB for each container. In our test, a container that printed 213 MB of text kept 4.6 MB on disk, anddocker logsstill showed it.live-restorekeeps containers running while Docker itself restarts, for example when it is updated. With it, our app's container kept running through an update; the port on127.0.0.1was gone for one to two seconds while Docker started again. It applies to containers you start yourself, not to Docker Swarm services.
Check the file and restart Docker:
dockerd --validate --config-file /etc/docker/daemon.json
systemctl restart docker
docker info | grep -E 'Logging Driver|Live Restore'
Logging Driver: local
Live Restore Enabled: true
The log settings only apply to containers created after the change. Recreate the ones you have:
cd /opt/whoami
docker compose up -d --force-recreate
7. Decide who can use Docker
Anyone who can run docker controls the server. Docker runs as root, and a container can mount the server's whole file system: a user we added to the docker group read /etc/shadow, which only root may read, with one docker run. So do not add users to the docker group unless you would give them root. Use Docker as root, as in this guide, or use rootless Podman for users who should only run their own containers.
8. Update Docker and your apps
Docker comes from its own repository, which the automatic updates from the security guide do not cover. To let them install Docker's updates too, create /etc/apt/apt.conf.d/52unattended-upgrades-docker:
Unattended-Upgrade::Origins-Pattern {
"origin=Docker,label=Docker CE";
};
This adds to Debian's sources, it does not replace them. To check:
unattended-upgrade --dry-run --debug 2>&1 | grep 'Allowed origins'
The line ends with origin=Docker,label=Docker CE. In our test, a run of unattended-upgrade updated Docker from 29.8.1 to 29.8.2, and with live-restore the app's container kept running. Docker's repository also delivers new major versions this way. If you would rather choose when, leave the file out and run apt update && apt upgrade yourself.
Apps update when you change their image. Change the version in compose.yaml, then pull the new image and recreate the container:
cd /opt/whoami
sed -i 's/whoami:v1.11.0/whoami:v1.12.0/' compose.yaml
docker compose pull
docker compose up -d
Compose only recreates containers whose image or settings changed. The app was away for one to two seconds. Read the app's release notes before a new major version: some need a step of their own, such as a database migration.
9. Clean up disk space
Old images stay on disk after an update. To see how much space Docker uses:
docker system df
docker system prune removes stopped containers, networks no container uses, build cache and images without a name. It asks first. Check docker ps -a before you run it: a container you stopped on purpose is removed too, although its named volumes are kept. Data in volumes is only removed with --volumes, and even then only volumes without a name.
Old versions of an image still have their name, so prune keeps them. To remove every image that no container uses:
docker image prune -a
In our test, this removed traefik/whoami:v1.11.0 and the test images, and kept the version the app runs. Any image you removed is downloaded again the next time something needs it.
10. Check after a reboot
reboot
After the restart, check that Docker, the rules and the app are back:
systemctl is-active docker docker-ports caddy
docker ps --format '{{.Names}} {{.Status}}'
iptables -S DOCKER-USER
All three services are active, the app's container is Up, and the DROP rule for port 8080 is there. In our test, https://app.example.com answered within a minute of the reboot, and port 8080 stayed closed over IPv4 and IPv6.
Troubleshooting
A port is open although ufw does not allow it. It is published by a container. docker ps shows each container's ports: 0.0.0.0: and [::]: mean every address. Publish it on 127.0.0.1, or add it to the service in step 3.
Job for docker.service failed because start of the service was attempted too often. Docker was restarted many times within a short time, and systemd stopped trying. We hit this while testing. Run systemctl reset-failed docker.service docker.socket, then systemctl start docker. The service from step 3 stops together with Docker: start it again with systemctl start docker-ports.service, and check with iptables -S DOCKER-USER.
Containers are stopped after you turned live-restore off. In our test, containers that were running when we switched it from true to false stopped with Docker's restart and did not start again by themselves. Start them with docker compose up -d in each app's directory. Switching it on did not stop them.
A container cannot reach IPv6 addresses: Network unreachable. It is on a network without IPv6, such as Docker's default network. Switch on IPv6 for its network as in step 4.
The app sees 172.18.0.1 as every visitor's address. That is the address Caddy connects from. The visitor's address is in X-Forwarded-For: set the app to trust that header from the proxy.