Installing Docker and Docker Compose on Debian and Ubuntu
Why docker.io is too old on Debian 12 but perfectly fine on Ubuntu, how to add the official repository with a keyring instead of apt-key, why Compose is a plugin now and why the docker group effectively means root.
Installing Docker takes five minutes. Installing Docker so that the server still receives current security updates a year later, the system disk does not fill up and not every user is quietly running with root privileges takes a little longer. This article covers the second variant, tested on Debian 13 (Trixie), Debian 12 (Bookworm), Ubuntu 24.04 (Noble) and Ubuntu 22.04 (Jammy).
docker.io or Docker CE: the difference hardly anyone explains honestly
There are two ways to get Docker. The package docker.io comes from the distribution's own package sources and is built and maintained by Debian or Ubuntu. The package docker-ce comes from Docker's own repository. Both contain the same software, but at very different ages.
These are the versions currently sitting in the distribution sources (measured with apt-cache policy in fresh containers):
| System | docker.io in the distribution sources |
| Debian 13 (Trixie) | 26.1.5 |
| Debian 12 (Bookworm) | 20.10.24 |
| Ubuntu 24.04 (Noble) | 29.1.3 |
| Ubuntu 22.04 (Jammy) | 29.1.3 |
That is the point that actually matters, and almost every guide out there boils it down to one blanket recommendation. The truth is more nuanced:
- Ubuntu 24.04 and 22.04:
docker.iosits at 29.1.3 and is therefore practically level with the current upstream release. If you have no special requirements, you can use the distribution package here with a clear conscience. Security updates then arrive through the normal Ubuntu channel. - Debian 13: 26.1.5 is usable, but a fair way behind upstream. For most use cases it is good enough.
- Debian 12: 20.10.24 is the problem case. That branch reached the end of its life upstream years ago. Debian does backport security patches, but many modern features are simply missing, among them a current BuildKit version and a good deal of Compose compatibility.
There is also a practical difference: docker-ce ships Buildx and Compose as separate plugin packages that match the engine. With docker.io you have to collect those parts yourself from docker-buildx and docker-compose-v2, and docker-compose-v2 exists only in the Ubuntu sources; on Debian the package is not available at all.
What does not work: running both side by side. The package containerd.io from the Docker repository conflicts with the package containerd from the distribution. You have to pick one.
Rule of thumb: on Ubuntu, docker.io is a legitimate choice. On Debian 12 it is not. For servers running Compose stacks with current syntax, Docker CE is the better decision everywhere.
Remove the old packages before anything else happens
If Docker is already present in some form, it has to go, otherwise the installation fails on package conflicts. The following loop removes all the usual suspects and catches packages that are not installed or that do not exist in your distribution at all:
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do sudo apt-get remove -y $pkg || true; done
The || true in the loop body is not cosmetic, it is necessary. On Debian, apt-get aborts on the entry docker-compose-v2 with E: Unable to locate package docker-compose-v2 and exit code 100, because that package exists only in the Ubuntu sources, not in bookworm or trixie (and not in bullseye either, where podman-docker is missing as well). The loop does carry on, but it leaves behind a non-zero exit code, and that is exactly what kills a script running with set -e or a chain built with &&. Messages along the lines of "Unable to locate package" are therefore normal at this point and may be ignored, which is how the official Docker documentation handles it too.
Worth knowing: nothing is lost in the process. Your images, containers and volumes live in /var/lib/docker, and apt-get remove does not touch that directory. Once Docker CE is installed, your containers are back. Only sudo rm -rf /var/lib/docker really deletes anything, and that is irreversible.
Storing the key correctly: apt-key is history
Plenty of guides on the internet still contain this line:
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo apt-key add -
That no longer does anything sensible on any of the four systems covered here. apt-key is deprecated and is not even present on Debian 13 any more. The reason is not cosmetic: a key in /etc/apt/trusted.gpg signs all repositories, not just the one it was meant for. A compromised mirror could use that to slip arbitrary packages onto your system.
The correct approach is a dedicated keyring under /etc/apt/keyrings/ that is tied to exactly one source via Signed-By.
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
sudo install -m 0755 -d /etc/apt/keyrings
The following command fetches the matching key and works on Debian just as well as on Ubuntu, because it reads the distribution ID from /etc/os-release:
sudo curl -fsSL "https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
And now the step that practically nobody describes: verify the fingerprint before you trust the key with your system.
gpg --show-keys /etc/apt/keyrings/docker.asc
The output must contain the fingerprint 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88 and the identity Docker Release (CE deb) <docker@docker.com>. If that does not match, stop right there. Something is then wrong with your connection or with the source.
Installing Docker CE with one snippet for all four systems
The official documentation shows separate blocks for Debian and Ubuntu. That is unnecessary. The following block writes the package source in the modern deb822 format and works out distribution, codename and architecture by itself:
sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
Two details that save time. First, ${UBUNTU_CODENAME:-$VERSION_CODENAME}: on Ubuntu derivatives such as Linux Mint, VERSION_CODENAME holds the name of the derivative, not the Ubuntu one. Second, the Architectures line: without it, apt complains on systems with the i386 foreign architecture enabled and prints a long warning about missing package lists.
Check the result before you carry on:
cat /etc/apt/sources.list.d/docker.sources
Suites has to read trixie, bookworm, noble or jammy. If anything else is in there, the next step runs into an error. Then comes the update and the installation:
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
The five packages are: the daemon, the command line tool, the container runtime, the modern image builder and Compose.
How to tell that it is really running
The fact that apt-get finished without errors only means that files are sitting on the disk. The following four checks show whether the system is actually working.
First: does the client reach the daemon?
docker version
What matters is not the Client section, but that a section Server: Docker Engine - Community with a version number appears below it. If it is missing, the daemon is not running or you are not allowed to access the socket.
Second: which storage driver is active?
docker info --format '{{.Driver}}'
docker info --format '{{.CgroupVersion}}'
There is a change lurking here that many older guides answer incorrectly. Since Docker Engine 29, fresh installations default to the containerd image store. The driver is then called overlayfs rather than overlay2. Both values are fine. What you do not want to see is vfs: this emergency driver copies every layer in full, eats several times the disk space and is painfully slow. It typically shows up when Docker runs in an environment without suitable kernel support. The cgroup version has to come out as 2 on all four systems.
If you have upgraded an existing system and suddenly all images appear to have vanished: they are not deleted. When the image store is switched, the contents of the other store are merely hidden, and they reappear as soon as you switch back. Switching back is done via /etc/docker/daemon.json:
{
"features": {
"containerd-snapshotter": false
}
}
Third: is a container really running?
docker run --rm hello-world
Fourth: do networking and name resolution work inside the container? This test is missing from almost every guide, even though this is exactly where most follow-up problems come from:
docker run --rm alpine:3 ping -c 2 1.1.1.1
docker run --rm alpine:3 nslookup deb.debian.org
If the ping answers but name resolution fails, the usual cause is a DNS server that listens on 127.0.0.53 only. That address is not reachable from inside the container. The remedy is an entry in /etc/docker/daemon.json with "dns": ["9.9.9.9"] and a restart of the daemon.
Compose is a plugin, not a separate program any more
The old docker-compose with a hyphen was a separate Python program. It has been discontinued and is no longer shipped. Its successor is a plugin written in Go that is called as a subcommand of the Docker CLI, so docker compose with a space.
docker compose version
A note on the version number, because it causes confusion on a regular basis: the term "Compose V2" refers to the rewrite in Go, not to the version number. The output today shows a version from the 5.x branch. That is correct and not some different product.
Two things stand out when you migrate. First, the version: key at the top of docker-compose.yml has become redundant and produces a warning:
WARN[0000] docker-compose.yml: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion
Just delete the line. Second, the naming changes: Compose derives the project name from the directory name and creates containers with a hyphen instead of an underscore, so myproject-web-1 instead of myproject_web_1. Scripts that address containers by fixed names break as a result. In such cases, set the project name explicitly via name: in the Compose file or via -p.
If you deliberately want to stay with the distribution packages, the matching package is called docker-compose-v2 and provides the same subcommand. It is available in the Ubuntu sources only, though. Check the available version first:
apt-cache policy docker-compose-v2
On Ubuntu 24.04 and 22.04 you get a table with the installed and the candidate version. On Debian 13 and Debian 12 the command prints nothing at all, an empty output with exit code 0. That is not an error, it is the answer: this package does not exist on Debian, and there the route to Compose leads through docker-compose-plugin from the Docker repository.
The docker group is root, just with an extra step
To let a normal user operate Docker without sudo, that user is usually added to the docker group:
sudo groupadd -f docker
sudo usermod -aG docker $USER
Group membership only takes effect on a new login. You can check it after logging in again with id -nG. If you would rather not log in again, newgrp docker starts a shell with the new group.
Now the part you have to understand: membership in the docker group is equivalent to root privileges on the entire server. That is not a theoretical judgement, it follows directly from the way the thing works. Anyone allowed to talk to the Docker socket can hand arbitrary jobs to the daemon, which runs as root. A single command is enough:
docker run -it -v /:/hostfs alpine:3 chroot /hostfs sh
The result is a root shell on the host system, without sudo, without a password prompt, without an entry in the sudo log. Reading /etc/shadow, dropping in SSH keys, replacing services: all of it is possible. The Docker documentation puts it briefly and unmistakably: "The docker group grants root-level privileges to the user."
Practical consequences for a server on the internet:
- Add only those accounts to the group that you would trust with root anyway.
- The user that a web application or a CI runner runs as does not belong in it. Otherwise a break-in into the application would automatically be a break-in into the server.
- If you need an audit trail, skip the group and call Docker via
sudo docker. Then at least the invocation shows up in the log. - For real separation there is rootless mode. It is set up through the package
docker-ce-rootless-extrasand the tooldockerd-rootless-setuptool.sh installand additionally requiresuidmap. The price: ports below 1024 cannot be bound without extra configuration, and some networking features behave differently.
Autostart: the trap is called docker.socket
The standard command is well known:
sudo systemctl enable --now docker.service
sudo systemctl enable --now containerd.service
Less well known is why disabling it often has no effect. Alongside docker.service, Docker also ships a docker.socket. That unit listens on the socket and starts the daemon automatically on first access. So if you run systemctl disable docker.service and then notice that Docker is running anyway, you are not seeing ghosts: the first docker ps pulled the daemon back up through the socket unit. Shutting it down completely takes both:
sudo systemctl disable --now docker.service docker.socket
You can check the state with systemctl is-enabled docker.service, which has to print enabled.
The second point concerns your containers. Whether a container comes back after a reboot is not decided by systemd but by the restart policy. And there is a difference here that catches people out regularly: always starts a container again even if you deliberately stopped it before the reboot. unless-stopped respects your manual stop across the reboot. For servers, unless-stopped is usually the right choice:
services:
web:
image: nginx:stable
restart: unless-stopped
The test for this is not docker ps, it is a real reboot of the server followed by a check.
Log rotation: the most common reason for a full system disk
Docker writes the output of every container into a JSON file under /var/lib/docker/containers/ by default. Out of the box, that file grows without any limit. A chatty reverse proxy can pile up double-digit gigabytes over a few months, until the server comes to a halt with no space left on device. The culprit is hard to find afterwards, because du in the application directory shows nothing unusual.
The fix belongs on every server, and it belongs there before the first problem shows up:
sudo mkdir -p /etc/docker
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
}
}
EOF
If a daemon.json already exists, this command overwrites it. Look first and add the keys by hand if necessary. After that:
sudo systemctl restart docker
Three points where it still goes wrong:
- The values have to be strings in quotes.
"max-file": 3without quotes stops the daemon from starting. - The setting applies to newly created containers only. Existing ones keep their old configuration until they are recreated, which with Compose means
docker compose up -d --force-recreate. - Deleting an overflowing log file with
rmdoes not give any disk space back, because the daemon still holds the file open. Usesudo truncate -s 0 <path>instead.
This is how you check whether the setting has taken effect for a specific container:
docker inspect --format '{{json .HostConfig.LogConfig}}' mycontainer
Cleaning up with docker system prune, without losing data
Unused images, aborted builds and the BuildKit cache add up. First have a look at where the space is going:
docker system df
docker system df -v
The standard cleanup command removes stopped containers, unused networks, untagged images and the build cache:
docker system prune
Two switches deserve respect. -a additionally deletes all images that are not currently used by a container, including carefully maintained base images. On a server with a thin uplink, pulling them again afterwards can take a while. Considerably more dangerous is --volumes: this switch removes named volumes that have no container attached. If your database container has just been deleted while the volume still holds the data, that data is gone afterwards. There is no recycle bin.
Never use --volumes in an automated cleanup job.
A grace period based on age makes sense, so that only genuinely old material disappears:
docker system prune -a --filter "until=168h"
docker builder prune --filter "until=168h"
As a weekly job, easy on resources at night and without deleting volumes, one line in /etc/cron.d/docker-prune is enough:
15 4 * * 0 root /usr/bin/docker system prune -af --filter "until=168h" > /dev/null 2>&1
The proof that it worked is another docker system df with a lower "RECLAIMABLE" column.
Error messages verbatim and what is behind them
permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock
The user is not in the docker group, or the group membership is not yet effective in the current session. Log in again or run newgrp docker.
Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
The daemon is not running. systemctl status docker gives you the cause and the exact wording, and journalctl -u docker -n 50 --no-pager gives more detail. Very often a broken /etc/docker/daemon.json is behind it. That file has to be valid JSON, one stray comma is enough to break it.
docker: 'compose' is not a docker command.
The plugin is missing. Install either docker-compose-plugin from the Docker repository or, on Ubuntu only, docker-compose-v2 from the distribution.
E: Conflicting values set for option Signed-By regarding source https://download.docker.com/linux/debian/ trixie: /etc/apt/keyrings/docker.asc != /etc/apt/keyrings/docker.gpg
The classic outcome of working through several guides in a row: an old /etc/apt/sources.list.d/docker.list and the new docker.sources exist at the same time. Delete the old file and repeat sudo apt-get update. Use ls -l /etc/apt/sources.list.d/ to get an overview.
The following signatures couldn't be verified because the public key is not available: NO_PUBKEY 8D81803C0EBFCD88
The key is not where Signed-By expects it, or the downloaded file is incomplete (for example because a proxy served an HTML error page). gpg --show-keys /etc/apt/keyrings/docker.asc shows immediately whether there is a key in it at all.
E: The repository 'https://download.docker.com/linux/debian trixie Release' does not have a Release file.
The codename does not match the source. This happens on derivative distributions and when Debian and Ubuntu guides get mixed. Check the URIs and Suites lines in docker.sources.
Bind for 0.0.0.0:80 failed: port is already allocated
Another service occupies the port, often a directly installed web server. sudo ss -tulpn | grep :80 names the offender.
Docker and the firewall: a word on security
One quirk that can get expensive on a publicly reachable server: Docker inserts its forwarding rules into the NAT table and thereby bypasses the rules you have carefully maintained in ufw. A container started with -p 5432:5432 is reachable from the internet even if ufw status does not permit the port anywhere. This still applies, even though Docker Engine 28 hardened the networking behavior overall and blocked external access to unpublished ports.
The simplest and most reliable countermeasure is to bind services that are only needed locally explicitly to the loopback address:
services:
db:
image: postgres:17
ports:
- "127.0.0.1:5432:5432"
restart: unless-stopped
Better still: do not publish such ports at all and let the containers talk to each other over a shared Docker network. The best way to verify the result is from a second machine, because a test from the server itself does not answer the question that matters.
In short
On Debian 12, take Docker CE in any case; on Ubuntu you are free to choose between docker.io and Docker CE. Add the repository with its own keyring and a verified fingerprint, not with apt-key. Use docker compose with a space. Treat the docker group like a root grant, because that is exactly what it is. And set up log rotation plus a weekly prune job before the server hits a full disk for the first time.
Related reading: Set up the UFW firewall on Debian and Ubuntu and Install Nginx on Debian and Ubuntu.
Frequently asked questions
Should I install docker.io or docker-ce?
Why does docker-compose with a hyphen no longer work?
Is the docker group really as dangerous as people claim?
Why does docker info show overlayfs instead of overlay2 on my system?
My images disappeared after an update, are they deleted?
How do I stop container logs from filling up the disk?
Is docker system prune safe?
2026 KernelHost GmbH. All rights reserved. This guide is protected by copyright. Republishing it on other websites, in whole, in part or in edited form, is not permitted without our written consent. Quoting with a source credit and a link is expressly welcome.

