GuidesDevelopers and hostingInstall Docker on Debian

Install Docker on Debian

Docker Engine and Compose on Debian 13 from Docker's own repository, with the ports Docker opens past ufw closed, IPv6 for containers, log rotation, and a first app behind Caddy that sees each visitor's real address.

Tested on Docker 29.8.2 and Docker Compose 5.5.1 on Debian 13 (trixie) on a Melonslab server Updated October 2, 2026

Recommended server for this guide

VC-S Micro · 2 vCPU · 8 GB Memory · 250 GB Storage

Month to month, no lock-in 7-day money-back guarantee

€7.99/mo

Deploy now
On this page

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.1 only, 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
}
  • local stores 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, and docker logs still showed it.
  • live-restore keeps 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 on 127.0.0.1 was 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.

Run it on your own server

VC-S Micro

€7.99/mo

vCPU
2
Memory
8 GB
Storage
250 GB
Transfer
10 TB
Standard
HDD · RAID 10
  • Full root access
  • Native /64 IPv6
  • RAID-protected storage
  • Malmö, Sweden
  • Month to month, no lock-in
  • 7-day money-back guarantee
All guides