> ## Content Index
> Fetch the complete content index at: https://blog.sudowl.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Installing Docker on Ubuntu the Right Way (Not the Snap Version)
- URL: https://blog.sudowl.com/docker-install-ubuntu/
- Published: 2026-04-07T10:00:00.000Z
- Updated: 2026-10-11T10:16:27.000Z
- Description: 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.
- Author: Lukas
- Tags: Linux, Ubuntu, Docker

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.

```bash
# 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 `/srv` or `/opt` into 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/docker` and `/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:

```bash
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

```bash
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](/linux-user-management-adduser-sudo-ssh-keys/).

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:

```json
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "20m",
    "max-file": "5"
  }
}

```

Put it in `/etc/docker/daemon.json`, then:

```bash
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](/linux-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](/logrotate-linux-log-rotation/) covers the host side; container logs are this config's job.

## Gotcha: published ports bypass UFW

This one catches basically everyone. You run:

```bash
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:

```yaml
# 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](/nginx-reverse-proxy-setup/)), 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](/ufw-firewall-rules-ubuntu/).

## 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:

```json
{
  "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

```bash
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](/docker-vs-podman/).

---

### Related posts

- [Nginx Reverse Proxy: The Config I Actually Use](https://blog.diengdoh.com/nginx-reverse-proxy-setup/?ref=blog.sudowl.com)
- [Ubuntu Server Initial Setup Checklist (2026 Edition)](https://blog.diengdoh.com/ubuntu-server-initial-setup-checklist/?ref=blog.sudowl.com)
- [KeyHelp: The Free Plesk Alternative I Moved To (and Its API Traps)](https://blog.diengdoh.com/keyhelp-free-plesk-alternative/?ref=blog.sudowl.com)