Installing Docker on Ubuntu the Right Way (Not the Snap Version)
Install Docker Engine + the compose plugin from Docker's official apt repo on Ubuntu/Debian, ditch the snap, and fix log growth and the UFW bypass.
Ubuntu offers you three different "Docker"s: the snap the installer cheerfully suggests, the docker.io package from the Ubuntu archive, and Docker's own apt repo. I only ever use the last one. The snap is confined in ways that bite you later, docker.io lags behind and ships compose in a different shape, and the official repo gives you current Docker Engine plus docker compose as a plugin, updated through normal apt upgrade.
TL;DR: remove whatever's there, add Docker's repo with a signed-by keyring, install docker-ce + docker-compose-plugin, set log rotation in daemon.json, and remember that published ports skip UFW.
# 1. get rid of the snap and distro packages
sudo snap remove --purge docker 2>/dev/null
for pkg in docker.io docker-doc docker-compose docker-compose-v2 podman-docker containerd runc; do
sudo apt-get remove -y $pkg
done
# 2. Docker's key + repo (deb822 format)
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
sudo tee /etc/apt/sources.list.d/docker.sources >/dev/null <<EOF
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
Components: stable
Signed-By: /etc/apt/keyrings/docker.asc
EOF
# 3. install
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
# 4. check
sudo docker run --rm hello-world
docker compose version
That's the whole install. The rest of this post is the stuff that matters a week later.
Why not the snap
The snap works for docker run hello-world. It starts hurting once you do real things:
- Confinement. The snap can only see a limited set of paths. Bind-mounting something from
/srvor/optinto a container is exactly the kind of thing that fails with confusing "no such file or directory" errors, because from inside the snap that path doesn't exist the way you think it does. - Everything lives somewhere else. Data is under
/var/snap/docker/common/var-lib-docker, the daemon config under/var/snap/docker/current/config/daemon.json. Every guide, every forum answer, every monitoring tool assumes/var/lib/dockerand/etc/docker/daemon.json. - Auto-refresh. Snaps update themselves. When the docker snap refreshes, the daemon restarts, and your containers restart with it. On a box running a game server or a database, "the daemon restarted on its own at some random hour" is not a feature.
docker.io from the Ubuntu archive is fine in the sense that it's maintained, but you get whatever version the release froze on, and on older releases compose was the separate Python docker-compose (v1, with the hyphen), which is dead. I don't want to think about which flavour a box has, so every box gets the same thing: the official repo.
If the box had the snap with containers you care about, back up the volumes before snap remove --purge. The --purge part means exactly what it says — the snap's data directory goes with it, no snapshot kept.
The repo line, explained
The .sources file is the deb822 format, which is what Ubuntu 24.04 itself uses now (/etc/apt/sources.list.d/ubuntu.sources). The important bit is Signed-By: — the Docker key is trusted only for this repo, not globally. The old apt-key add way trusted the key for every repo on the system, and apt-key is gone anyway.
The Suites: line resolves to your codename at install time (noble on 24.04). Check what got written:
cat /etc/apt/sources.list.d/docker.sources
apt-cache policy docker-ce | head -5
apt-cache policy should show the candidate coming from download.docker.com. If it shows nothing, the repo line is wrong — usually the codename.
Debian 12/13: same steps, but the URLs are https://download.docker.com/linux/debian/gpg and https://download.docker.com/linux/debian, and the suite is just $VERSION_CODENAME (bookworm / trixie). Don't paste the Ubuntu repo onto Debian; it'll often install, and then break on the next dist upgrade in fun ways.
Running docker without sudo
sudo usermod -aG docker $USER
# log out and back in, or for the current shell:
newgrp docker
docker ps
Know what you're doing here: membership in the docker group is root-equivalent. Anyone in it can docker run -v /:/host and edit /etc/shadow. I add my own admin user and nobody else. Service accounts and deploy users don't get it unless I've decided that user is effectively root anyway. More on users and groups in Linux user management.
The -a in usermod -aG matters — without it you replace all secondary groups, and on Ubuntu that includes sudo. That's how people lock themselves out of sudo while setting up Docker.
daemon.json: set log rotation before anything else
This is the first thing I do after install, before the first real container:
{
"log-driver": "local",
"log-opts": {
"max-size": "20m",
"max-file": "5"
}
}
Put it in /etc/docker/daemon.json, then:
sudo systemctl restart docker
docker info --format '{{.LoggingDriver}}'
The default json-file driver has no rotation at all. A chatty container writes to /var/lib/docker/containers/<id>/<id>-json.log forever, and some weeks later the disk is full and you're doing disk-full forensics at a bad time. The local driver is compact and rotates; docker logs still works with it. If you need json-file because some log shipper reads those files directly, keep json-file and set the same max-size / max-file options.
Note that logrotate doesn't help here — Docker owns those files. The logrotate post covers the host side; container logs are this config's job.
Gotcha: published ports bypass UFW
This one catches basically everyone. You run:
sudo ufw default deny incoming
sudo ufw allow 22/tcp
docker run -d -p 8080:80 nginx
and port 8080 is reachable from the internet anyway. Docker writes its own iptables NAT rules for published ports. That traffic gets DNAT'ed and goes through the FORWARD chain to the container — it never hits the INPUT rules UFW manages. ufw status looks perfect and lies to you.
What I actually do:
# compose.yaml — bind to localhost, let the reverse proxy face the world
services:
app:
image: nginx:stable
ports:
- "127.0.0.1:8080:80"
Everything that's meant to be public goes through a reverse proxy on 80/443 (see nginx reverse proxy), and every app container publishes on 127.0.0.1 only. For things that must be public but restricted by source IP, Docker leaves you the DOCKER-USER chain, which is evaluated before its own rules — that's where filtering for container traffic belongs, not in UFW. The general firewall side is in UFW firewall rules.
Gotcha: docker-compose vs docker compose
With the plugin, it's docker compose — space, not hyphen. Old scripts and READMEs calling docker-compose will fail with "command not found". Fix the scripts rather than installing the dead v1 binary. The file name compose.yaml is the current default; docker-compose.yml still gets picked up.
Gotcha: upgrades restart the daemon
apt upgrade touching docker-ce or containerd.io restarts the Docker daemon, and by default that stops all containers. Containers with restart: unless-stopped come back, the rest don't. Two options:
- Give everything a restart policy (I do this anyway).
- Or enable live restore so containers keep running while the daemon restarts:
{
"live-restore": true
}
Merge that into the same daemon.json as the log config, not a second file. If you run unattended-upgrades, decide consciously whether the Docker repo is in the allowed origins — I'd rather update Docker by hand on boxes where a restart costs something.
Quick sanity check after install
systemctl is-enabled docker containerd # both "enabled"
docker info | grep -E 'Server Version|Storage Driver|Logging Driver|Cgroup'
docker compose version
ls /var/lib/docker # not under /var/snap
You want overlay2 as the storage driver, local (or json-file with limits) as the logging driver, and cgroup v2. If all that checks out, the host is done and you can start putting actual workloads on it. Wondering whether Docker is even the right engine? I compared it in Docker vs Podman.