Ubuntu 26.04 gives you two ways to install Docker, and the copy in Ubuntu’s own archive runs eight minor releases behind. The docker.io package in the resolute archive is still on the 29.1 line, while Docker’s apt repository already carries the current Docker Engine, Compose v5 and Buildx built for resolute. For a host you plan to keep, we’d install Docker CE from Docker’s repository and leave the archive package for machines that may only pull from Ubuntu mirrors.
This guide installs Docker CE on Ubuntu 26.04 LTS from the deb822 repository, then sets up sudo-free use, log rotation and live restore. It also closes the firewall gap that catches most new installs, because a port you publish with -p answers from the network even when UFW denies everything. We proved that from a second machine against a fresh Ubuntu 26.04 VM, then tested two fixes and a full uninstall.
Current as of October 2026.
What you need
Docker itself is light, so the size of the host depends on what you run in it, not on the engine. On our 2 vCPU, 4 GB test VM the daemon used about 98 MB of resident memory and containerd about 50 MB after the first container, and the packages took 388 MB of disk. That means you should size the box from your workloads, and the list below says where each requirement comes from:
- A 64-bit Ubuntu 26.04 LTS server or desktop: Docker’s repo builds resolute packages for amd64, arm64, armhf, s390x and ppc64el, though on the day we checked the s390x and ppc64el builds were one minor release behind the others. Our lab ran amd64.
- A user with sudo: every install step uses it. The cloud image we tested already had
vim,curlandca-certificates. - Disk for images, not for Docker: budget the 388 MB for the packages, then the sum of your images and volumes plus about 30% headroom, because image layers and build cache grow quietly.
- A second machine on the network: only for the firewall test in step 6, so you can see what an outside client sees.
We tested with Docker Engine 29.9.1, containerd 2.4.2, Buildx 0.38.0 and Compose 5.6.0 on Ubuntu 26.04.1. The repository steps below read the codename from the system, so they work unchanged on Ubuntu 24.04. Debian 13 uses a different repository path, and Docker and Docker Compose on Ubuntu 24.04 and Debian 13 covers that pair with Compose stacks on top. If containers are new to you in general, our shortlist of the Docker books worth reading covers the concepts this page assumes you already have.
1. Remove conflicting packages
Ubuntu’s archive ships its own Docker, containerd and runc packages, and Docker’s install page for Ubuntu warns that they can conflict with its own builds, so it lists eight package names to clear first. The one-liner below asks dpkg which of them are installed and removes only those, which means it does nothing at all on a clean system:
sudo apt remove -y $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
On our fresh VM dpkg printed “no packages found” for all eight names and apt reported Removing: 0, which is the healthy result. If you’re replacing an old docker.io install, you’ll see those packages listed for removal instead. Let apt remove them now, because adding Docker’s repository first just moves the same conflict into step 3.
2. Add Docker’s deb822 apt repository
Docker now documents its repository as a deb822 .sources file instead of the one-line docker.list that older guides used. Both formats work, but you should keep one or the other, because apt reads every file in sources.list.d and you’d end up with the same repository configured twice. Install the two tools the key download needs and fetch Docker’s signing key.
sudo apt update
sudo apt 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
Check the fingerprint before you trust the key. A tampered or truncated download is cheap to catch here and expensive to catch after apt has used it to verify packages. The fingerprint you want is 9DC858229FC7DD38854AE2D88D81803C0EBFCD88:
gpg --show-keys /etc/apt/keyrings/docker.asc
It belongs to the Docker Release (CE deb) key, and anything else means the download is not Docker’s key. On a fresh account gpg also prints a few lines about creating ~/.gnupg, which are harmless and left out of this excerpt:
pub rsa4096 2017-02-22 [SCEA]
9DC858229FC7DD38854AE2D88D81803C0EBFCD88
uid Docker Release (CE deb) <[email protected]>
sub rsa4096 2017-02-22 [S]
Now write the source file. The command reads the codename and the CPU architecture from the running system, so it gives resolute and amd64 on our VM and still works if you paste it on an arm64 box. Nothing in it needs editing:
printf 'Types: deb\nURIs: https://download.docker.com/linux/ubuntu\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources
tee echoes what it wrote. The Suites line is the one to check, because a wrong codename is the usual reason the next step finds no packages:
Types: deb
URIs: https://download.docker.com/linux/ubuntu
Suites: resolute
Components: stable
Architectures: amd64
Signed-By: /etc/apt/keyrings/docker.asc
Refresh the index and confirm that apt now sees Docker’s build of the engine. Until the refresh runs, apt works from the old index and doesn’t know the new packages exist:
sudo apt update
apt-cache policy docker-ce | head -3
A candidate ending in ubuntu.26.04~resolute means the repository works. If the candidate says (none) instead, the Suites line is wrong or the key didn’t download, so go back and check both before you install anything:
docker-ce:
Installed: (none)
Candidate: 5:29.9.1-1~ubuntu.26.04~resolute
The 5: prefix is Docker’s epoch, and the rest names the build for resolute. With that candidate in place the repository side is done.
3. Install Docker CE, Compose and Buildx
Five packages make a full install: the engine, the CLI, containerd, and the Buildx and Compose CLI plugins. Install them in one transaction so that apt resolves them against each other and you don’t end up with a CLI newer than the engine:
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Apt also pulls docker-ce-rootless-extras and pigz as dependencies, so seven packages land in total. The whole install finished in about 29 seconds on our VM, and this excerpt of the transaction summary shows the sizes:
Installing:
containerd.io docker-ce docker-compose-plugin
docker-buildx-plugin docker-ce-cli
Installing dependencies:
docker-ce-rootless-extras pigz
Summary:
Upgrading: 0, Installing: 7, Removing: 0, Not Upgrading: 3
Download size: 102 MB
Space needed: 388 MB / 27.0 GB available
Unlike the RPM distros, Ubuntu starts and enables docker.service and containerd.service during the install, so there is no systemctl enable step for you to run. Check the versions and the service in one go.
docker --version
docker compose version
docker buildx version
sudo systemctl status docker --no-pager | head -6
All three tools report the current release, and the service shows active (running) with the enabled preset. If it says failed instead, journalctl -u docker holds the reason:
Docker version 29.9.1, build dfc520d
Docker Compose version v5.6.0
github.com/docker/buildx v0.38.0 0dbe87f36ed472fe36b6796c3f70b4210a731fc5
● docker.service - Docker Application Container Engine
Loaded: loaded (/usr/lib/systemd/system/docker.service; enabled; preset: enabled)
Active: active (running) since Sun 2026-10-18 06:27:10 UTC; 7s ago
Invocation: 75d01337e779488eb7a45bfe0acdb348
TriggeredBy: ● docker.socket
Docs: https://docs.docker.com
The capture below is the same check on the lab VM, right after the install.

A fresh install on Ubuntu 26.04 uses the containerd image store, which docker info shows as Storage Driver: overlayfs with driver-type: io.containerd.snapshotter.v1. It also runs cgroup v2 with the systemd driver and the iptables firewall backend. Keep the image store in mind, because one popular daemon.json key switches it back to the old driver and hides your images (step 7 shows what we saw).
4. Run your first container
The hello-world image is the quickest end-to-end test, because it makes the daemon pull from Docker Hub, create a container and stream its output back to you. Run it with sudo for now, since your account cannot reach the socket yet.
sudo docker run hello-world
The pull and the greeting confirm that DNS, registry access and the runtime all work. This is the top of the output:
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
4f55086f7dd0: Pulling fs layer
4f55086f7dd0: Download complete
4f55086f7dd0: Pull complete
d5e71e642bf5: Download complete
Digest: sha256:11a3eb1597e864c51b3f56733cb2e8a90bf0c284bfc05f9444d81c558bc848aa
Status: Downloaded newer image for hello-world:latest
Hello from Docker!
This message shows that your installation appears to be working correctly.
If the pull hangs or fails with a TLS or DNS error instead, fix the host’s outbound access before you go further, because every later step pulls an image.
5. Use Docker without sudo
Your regular account cannot talk to the daemon yet, so the same CLI fails without sudo. Try it once so you recognise the error.
docker ps
The command fails with the error below, and the fix follows it.
Error: “permission denied while trying to connect to the docker API at unix:///var/run/docker.sock”
The socket belongs to root and the docker group, and your account is not in that group yet. This is the message you get.
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
Adding yourself to the group fixes it, but understand what you’re granting first. Anyone in docker can start a container that mounts / from the host, so membership is root access in practice. Give it only to the accounts that would get sudo anyway:
sudo usermod -aG docker $USER
Group changes apply to new logins only, which is why the error does not go away on its own. Log out and back in, or start a shell that has the new group right away:
newgrp docker
Test it without sudo. The --rm flag deletes the container once it exits, so your test runs don’t pile up in docker ps -a:
docker run --rm hello-world
We ran that through newgrp and got the same “Hello from Docker!” greeting with exit code 0, and id -nG inside the new shell listed docker first. If you would rather not hand out root-equivalent access at all, rootless mode runs the daemon as your own user instead. On Ubuntu 26.04 the docker-ce-rootless-extras package is already installed by step 3, the apparmor package ships a rootlesskit profile for /usr/bin/rootlesskit, and your account already has a subordinate ID range in /etc/subuid. On our VM the uidmap package was missing, and dockerd-rootless-setuptool.sh check also refused to continue while the rootful daemon was running, so plan to install uidmap and stop the system daemon first. We didn’t run a rootless daemon on this VM, but our Docker on Linux guide walks through the rootless setup on Ubuntu 26.04 end to end.
6. Docker published ports bypass UFW
Docker’s own install page warns that ports you expose with Docker bypass ufw and firewalld rules, and on Ubuntu 26.04 we saw exactly that. Docker’s packet filtering notes explain why: container traffic is routed in the nat table before UFW’s rules apply. We enabled UFW with a default deny policy and published nginx on 8080. Then we ran a plain Python web server on 8081 as a host service and tried both from a second machine on the same network:
| Service | How it listens | UFW rule | From the second host |
|---|---|---|---|
| nginx container | -p 8080:80 | none | HTTP 200 |
| Python http.server | host process on 8081 | none | timed out (000) |
| nginx container | -p 127.0.0.1:8080:80 | none | timed out (000) |
UFW blocked the host service as designed and did nothing for the container. You can repeat the test in a few commands. If UFW isn’t set up on the host yet, our UFW setup guide covers profiles and logging. Enabling UFW over SSH can lock you out, so allow SSH before you turn it on, and keep a console session open if the box is remote. The OpenSSH profile opens port 22 only, so if your sshd listens elsewhere (check with sudo ss -tlnp | grep sshd), add sudo ufw allow <port>/tcp with your port before the enable line. The --force flag skips the confirmation prompt, which is why the allow rules have to come first:
sudo ufw allow OpenSSH
sudo ufw --force enable
sudo ufw status verbose
The status should show deny (incoming) and only your SSH rule. If the port your sshd uses isn’t listed, add it now, before you close your current session.
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp (OpenSSH) ALLOW IN Anywhere
22/tcp (OpenSSH (v6)) ALLOW IN Anywhere (v6)
Publish nginx on all interfaces, the way most tutorials do it. This is the mapping that answered our second host in the table above.
docker run -d --name web -p 8080:80 nginx:alpine
From any other machine, request the port, replacing 10.0.1.50 with your Docker host’s address. A 200 means the container is reachable from the network, and 000 after a pause means something filtered it.
curl -s -o /dev/null -w '%{http_code}\n' http://10.0.1.50:8080
Our second host got 200 back, with UFW active and no rule for 8080. Docker adds a DNAT rule in the nat table that rewrites traffic for 8080 to the container’s address before any filter rule runs, so the packet is forwarded to the container rather than delivered to the host. UFW’s allow and deny rules sit on the INPUT path, which that packet never takes, so they never see it. On the FORWARD path Docker’s chains come first, and you can see both rules on your own host:
sudo iptables -t nat -S DOCKER
sudo iptables -S FORWARD
The DNAT rule sends 8080 to 172.17.0.2, and DOCKER-USER and DOCKER-FORWARD run before any ufw-* chain:

That ordering decides which fix works. Going by it, a ufw route rule can’t help either, because UFW’s forward chains only run after Docker’s chains have accepted the packet (we didn’t test ufw route rules). Pick one of these instead:
- The service only needs local access or sits behind a reverse proxy: bind it to loopback with
-p 127.0.0.1:8080:80. It’s the fix we’d default to, because it needs no firewall state at all. - The port must be reachable, but only from your own network: add an allowlist to the
DOCKER-USERchain, which Docker leaves for your rules and evaluates before its own. - The port is meant to be public: keep
-p 8080:80and accept that UFW is not the control for it.
Bind published ports to loopback
Recreate the container with the host address in the port mapping. Docker then listens on 127.0.0.1 only, so the port is not reachable from outside whatever the firewall does:
docker rm -f web
docker run -d --name web -p 127.0.0.1:8080:80 nginx:alpine
The mapping now shows 127.0.0.1:8080->80/tcp. A local curl http://127.0.0.1:8080 returned 200 on the host, and the same request from the second host timed out with 000. Put Nginx or Caddy on the host in front of anything you bind this way, and let UFW handle 80 and 443 for that proxy as it would for any host service.
Allow only your network with DOCKER-USER
When a port has to stay reachable from a trusted subnet, drop everything else in DOCKER-USER. You need a container that publishes on all interfaces for this, so replace the loopback-only web from the previous subsection. The restart policy brings it back after a reboot, which the last check below relies on:
docker rm -f web
docker run -d --name web --restart unless-stopped -p 8080:80 nginx:alpine
Now find the interface that faces the network and the subnet you trust. ip route gives the interface that holds the default route, and the link-scope route on that interface is the LAN it sits on. If you trust a different network than the one the host is attached to, set TRUSTED_NET by hand instead:
export EXT_IF=$(ip route show default | awk '{print $5; exit}')
export TRUSTED_NET=$(ip -4 route show dev "${EXT_IF}" scope link | awk '{print $1; exit}')
echo "${EXT_IF} ${TRUSTED_NET}"
On our VM that printed the interface and the /24 it sits on:
eth0 10.0.1.0/24
Check that the second value is a network in CIDR form before you go on. We tried an empty value on purpose: the DROP line became invalid, the unit failed to start with status=2/INVALIDARGUMENT, the chain stayed empty and our second host still got 200. A failed unit therefore leaves your ports open, not closed.
Rules you add by hand with iptables survive systemctl restart docker (we checked) but not a reboot, so we put them in a small systemd unit that runs after Docker starts. Open the unit file in vim. Any name works, but keeping docker in it makes the unit easy to find when you remove Docker later.
sudo vim /etc/systemd/system/docker-user-rules.service
Paste this unit as it is. The two placeholders get filled from your variables by the sed in the next command, which saves you from editing them by hand:
[Unit]
Description=Allowlist for Docker published ports
After=docker.service
Wants=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStartPre=/usr/sbin/iptables -F DOCKER-USER
ExecStart=/usr/sbin/iptables -I DOCKER-USER -i EXT_IF_HERE ! -s TRUSTED_NET_HERE -j DROP
ExecStart=/usr/sbin/iptables -I DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
ExecStop=/usr/sbin/iptables -F DOCKER-USER
[Install]
WantedBy=multi-user.target
Three details in that unit matter:
ExecStartPreandExecStopflush the chain: restarting the unit never stacks duplicate rules, and stopping it removes them. The unit ownsDOCKER-USER, so keep any other DOCKER-USER rules in this file too.- The conntrack rule is required: Docker’s iptables page documents it separately from the DROP example, and it’s easy to miss. Without it the DROP also drops the replies to connections your containers open, so a container can no longer resolve DNS or download anything.
- Order comes from
-I: each-Iinserts at the top, so the conntrack rule runs last and ends up first in the chain.
Fill in the placeholders, then start the unit and enable it for boot.
sudo sed -i -e "s|EXT_IF_HERE|${EXT_IF}|" -e "s|TRUSTED_NET_HERE|${TRUSTED_NET}|" /etc/systemd/system/docker-user-rules.service
sudo systemctl daemon-reload
sudo systemctl enable --now docker-user-rules.service
sudo iptables -L DOCKER-USER -n -v --line-numbers
Confirm the unit is running with systemctl is-active docker-user-rules.service, which must print active. An empty chain in the listing below also means it failed. To correct a wrong subnet, edit the -s value in the unit file by hand (the placeholders are gone after the first sed), then run sudo systemctl daemon-reload and sudo systemctl restart docker-user-rules.service. The RETURN rule must sit at line 1 and the DROP at line 2. If the order is reversed, your containers lose outbound access exactly as the error below shows:
Chain DOCKER-USER (1 references)
num pkts bytes target prot opt in out source destination
1 0 0 RETURN all -- * * 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
2 0 0 DROP all -- eth0 * !10.0.1.0/24 0.0.0.0/0
Repeat the request from your second machine. A host inside TRUSTED_NET should still get 200, and a host outside it should time out with 000:
curl -s -o /dev/null -w '%{http_code}\n' http://10.0.1.50:8080
Our second host sits inside the trusted subnet and got 200, and a container on the Docker host could still download from the internet. To see the other side, we changed the subnet in the unit file to 192.0.2.0/24, which the second host isn’t in, then ran daemon-reload and restarted the unit. The same request then timed out while curl on the Docker host itself still got 200, and the chain held exactly two rules, so the flush had removed the old pair. That is also how you change the trusted network later: edit the subnet in the unit, reload, restart. We restarted the unit twice more and restarted Docker without any rule being duplicated. systemctl disable --now docker-user-rules.service left the chain empty, and after a reboot with the unit enabled, both rules were back in the same order and the restart policy had brought web up again.
The DROP matches everything forwarded in on EXT_IF, not only container traffic. On a host that also routes for other machines, such as a WireGuard gateway, a Tailscale subnet router or a libvirt host, add the networks it forwards for to the unit before you enable it.
Error: “wget: bad address” from inside containers
We hit this when we tested the DROP rule without the conntrack rule in front of it. Every outbound lookup from a container failed, because the DNS reply arrives on the external interface from an address outside the trusted subnet, and the DROP rule matches it. You can reproduce it with any download from a throwaway container:
docker run --rm alpine wget -q -T 10 -O /dev/null https://download.docker.com/linux/ubuntu/gpg
The container couldn’t resolve the name and exited with code 1, while DNS on the host itself kept working:
wget: bad address 'download.docker.com'
Inserting -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN above the DROP fixed it, and the same command then exited 0. The unit above already includes that rule, so you only meet this error if you build the chain by hand.
7. Set log rotation and live restore
Two daemon settings are worth turning on for every server you run, and both live in /etc/docker/daemon.json, a file the packages don’t create for you:
- Log rotation: the default
json-filedriver never rotates, so a chatty container can fill the disk. We cap each container at three 10 MB files. - Live restore: containers keep running while the daemon restarts. Docker supports it across patch releases only (a change in the last number of the version), not for bigger upgrades, and skipping releases can leave the daemon unable to reattach to its containers.
Create the file with vim. It lives under /etc, so the editor needs sudo.
sudo vim /etc/docker/daemon.json
This is the exact file we ran. If you already have a daemon.json, merge these keys into it instead of replacing it:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
A syntax error in this file stops the daemon from starting, so validate it before the restart. dockerd --validate checks the file without touching the running engine:
sudo dockerd --validate --config-file=/etc/docker/daemon.json
sudo systemctl restart docker
docker info | grep -E 'Storage Driver|Logging Driver|Live Restore'
The validator printed configuration OK, and docker info confirmed both settings with the containerd image store still in place:
Storage Driver: overlayfs
Logging Driver: json-file
Live Restore Enabled: true
Log options apply only to containers created after the change, so your existing containers keep logging without limits until you recreate them. A container we started before the edit kept {json-file map[]} in docker inspect -f '{{.HostConfig.LogConfig}}', while one created after it showed {json-file map[max-file:3 max-size:10m]}.
Live restore behaved as documented, with one catch you should plan for. We stopped docker.service and docker.socket with nginx running. The nginx process kept the same PID, and the second host still got 200 on port 8080, because the DNAT rules stay in the kernel. A curl to 127.0.0.1:8080 on the host itself failed until the daemon came back, though, because local connections to a published port go through docker-proxy, and those processes exit with the daemon. Anything on the host that talks to a container through a published port loses it for the length of the restart.
Error: “start of the service was attempted too often”
We triggered this while testing the file, by restarting Docker several times in under a minute. docker.service allows three starts in 60 seconds (StartLimitBurst=3, StartLimitIntervalUSec=1min), so the fourth restart fails with this message:
Job for docker.service failed because start of the service was attempted too often.
See "systemctl status docker.service" and "journalctl -xeu docker.service" for details.
To force a start use "systemctl reset-failed docker.service"
followed by "systemctl start docker.service" again.
Clear the counter and start the daemon. Waiting a minute works too, because the limit only counts starts inside that 60-second window.
sudo systemctl reset-failed docker.service
sudo systemctl start docker
With live restore on, our nginx container kept running through the failed restarts and Docker reattached to it on the next start. Without live restore, the daemon shuts its containers down when it stops, so the same mistake would have taken nginx down too.
Images and containers vanish after adding “storage-driver”: “overlay2”
Older guides, including the April version of this page, put "storage-driver": "overlay2" in daemon.json. On a fresh install of the current engine that line switches Docker from the containerd image store back to the legacy overlay2 driver, and each store only shows what was created in it. We tested both directions on the lab VM:
- On the containerd store we pulled
alpineand started a container. - After adding the overlay2 line and restarting,
docker imagesanddocker ps -awere empty. - On overlay2 we pulled
hello-worldand started an nginx container with--restart unless-stopped. - Switching to the containerd store brought back the first image and container and hid the second set. The hidden nginx was not running, although its 71 MB stayed in
/var/lib/docker/overlay2. - Putting the overlay2 line back showed the second set again, and the restart policy started nginx.
Nothing was deleted in either direction, so the switch is reversible, but every switch hides one set of images and containers and stops the containers in it. Named volumes sit outside both stores, and a volume we created stayed in docker volume ls through both switches. Removing the line is not enough to go back either. Once /var/lib/docker/overlay2 exists, the daemon keeps using it, and the journal said so:
level=info msg="[graphdriver] using prior storage driver: overlay2"
What you should do depends on how long the host has run with that line:
- Your images vanished right after you added it: delete the
storage-driverline, add"features": { "containerd-snapshotter": true }in its place and restart Docker. That brought back all our images and containers. - The host has run on overlay2 for a while, for example since the April guide: keep
"storage-driver": "overlay2". Your running workloads live in that store, and switching would hide them. - You want the containerd store on such a host: migrate on purpose. Save the images you need with
docker saveand load them after the switch, or rebuild from your compose files, during a maintenance window.
Delete the old line rather than adding the new key next to it. We tried both keys in one file, and dockerd --validate still printed configuration OK, but the restart failed and the journal showed why:
failed to start daemon: configured driver "overlay2" not available: unavailable
With the containerd store switched on explicitly, Docker treats overlay2 as the name of a containerd snapshotter, and that snapshotter is called overlayfs, so the daemon refuses to start. Removing the storage-driver line, then sudo systemctl reset-failed docker.service and sudo systemctl start docker brought it back on the containerd store with our image and named volume visible. Keep the rest of your file, so your log and live-restore settings stay. One more lab detail, so you can match our results: before applying the final daemon.json above, we stopped Docker and deleted the leftover /var/lib/docker/overlay2 directory from this experiment. A host that never had the overlay2 line has no such directory and needs neither step.
8. Test Docker Compose
Compose v5 ships as the docker compose plugin, not the old docker-compose binary, and a one-service file is enough to prove that the plugin, the network setup and the image pull all work together. Create a project directory and the file:
mkdir -p ~/hello-compose && cd ~/hello-compose
vim compose.yaml
Paste this service. It publishes on loopback for the reasons in step 6, and restart: unless-stopped brings it back after a reboot unless you stopped it yourself. Leave that line out for throwaway tests.
services:
web:
image: nginx:alpine
ports:
- "127.0.0.1:8081:80"
restart: unless-stopped
Start it and let --wait block until the container is up, so the curl that follows doesn’t race the container start. Then request the page and tear the stack down:
docker compose up -d --wait
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8081
docker compose down
The plugin creates the project network and container and reports them healthy, then curl returns 200. These are the key progress lines, trimmed from the full output:
Network hello-compose_default Created
Container hello-compose-web-1 Created
Container hello-compose-web-1 Started
Container hello-compose-web-1 Healthy
That is the plugin working. For multi-container stacks with databases, .env files and reverse proxies, continue with Docker Compose on Ubuntu 26.04. To watch what those containers use over time, Prometheus and Grafana for Docker is the setup we run.
9. Uninstall Docker CE completely
Removing Docker cleanly takes one step most guides skip: you have to remove the containers first. With live restore on, apt purge stops the daemon but not the containers, and that is exactly what happened on our VM. The nginx container and its containerd-shim were still running after the packages were gone, which left its root filesystem mounted.
Error: “rm: cannot remove ‘/var/lib/docker/rootfs/overlayfs/…’: Device or resource busy”
Deleting /var/lib/docker then fails on the mounted path of the orphaned container. The path in the message is that container’s root filesystem, still mounted by the shim:
rm: cannot remove '/var/lib/docker/rootfs/overlayfs/02713f61c92549b413f19b80c6fda5a8748ac716df48b893db78d9ae5eabb8b7': Device or resource busy
A reboot clears it, but it is easier not to cause it at all. Remove the containers first, and if you created the DOCKER-USER unit from step 6, stop and delete it while Docker is still installed. Stopping it flushes its rules, which left the chain empty on our VM:
docker ps -aq | xargs -r docker rm -f
sudo systemctl disable --now docker-user-rules.service
sudo rm /etc/systemd/system/docker-user-rules.service
Skip the two docker-user-rules lines if you never created the unit. Then purge the packages and delete what they leave behind:
sudo apt purge -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin docker-ce-rootless-extras
sudo rm -rf /var/lib/docker /var/lib/containerd /etc/docker
sudo rm /etc/apt/sources.list.d/docker.sources /etc/apt/keyrings/docker.asc
sudo groupdel docker
The purge freed 388 MB, and with the containers removed first the rm -rf exited 0 with no containerd-shim left behind. apt purge leaves your daemon.json in place, which is why /etc/docker is in the delete list. Two things stay on purpose: UFW remains enabled with the rules you gave it, and pigz stays until you run sudo apt autoremove. Docker’s own iptables chains stay in the running kernel until the next reboot. They do nothing without the daemon, so the reboot is a tidiness step rather than a requirement.
docker.io or Docker CE: which one to run
Both packages install the same Moby engine, so the choice comes down to who builds it and how quickly it moves. This is what each source offered on Ubuntu 26.04 the day we tested:
| Ubuntu archive | Docker’s repository | |
|---|---|---|
| Engine package | docker.io 29.1.3 | docker-ce 29.9.1 |
| Compose | docker-compose-v2 2.40.3 | docker-compose-plugin 5.6.0 |
| containerd | containerd 2.2.2 | containerd.io 2.4.2 |
| Updates come from | Ubuntu, in the normal archive | Docker, as soon as a release ships |
| Repository setup | none | key plus docker.sources |
For most hosts the decision is short:
- Servers and workstations you keep: Docker CE. You get new engine and Compose releases within days, and the commands in Docker’s documentation match what you run.
- Hosts that may only pull from Ubuntu mirrors:
docker.io. It comes from the universe component and updates through Ubuntu’s archive, just older, and it conflicts with the packages in step 3, so pick one per machine. - Machines that need the current Compose release: Docker CE. The archive’s Compose is still on the 2.x line.
If you manage a mixed fleet, our Docker on Linux guide covers the same repository on Debian, Rocky Linux, AlmaLinux and Fedora, so every host you run gets the same engine build.