Skip to content

guide

Install Docker Engine on Ubuntu, Debian and AlmaLinux 9

The distribution's docker.io package works, but it trails upstream and packages Compose differently. Docker's own repository gives you the current engine, Buildx and the Compose plugin, with security fixes the day they ship.

Last updated

Who this is for

Anyone setting up a fresh server to run containers: a single app, a Compose stack, a CI runner or a small homelab-style collection of services. If you are deciding whether one VPS is the right shape for your containers at all, start with Docker hosts. If Docker is already installed and you want to run a stack properly, skip to Compose in production.

Ubuntu 24.04 and 22.04

Remove anything that would conflict. On a fresh FastNode image most of these won't be present, and apt will just say so:

for p in docker.io docker-doc docker-compose docker-compose-v2 \
         docker-buildx podman-docker containerd runc; do
  sudo apt-get remove -y "$p" 2>/dev/null
done

Add Docker's key to /etc/apt/keyrings and check its fingerprint before trusting it:

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
gpg --show-keys /etc/apt/keyrings/docker.asc

The fingerprint should read 9DC8 5822 9FC7 DD38 854A E2D8 8D81 803C 0EBF CD88. Now write the source in the newer deb822 format, which apt on 24.04 prefers:

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
Architectures: $(dpkg --print-architecture)
Signed-By: /etc/apt/keyrings/docker.asc
EOF
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

The suite resolves to noble on 24.04 and jammy on 22.04. The service is enabled and started by the package.

Debian 12 and 13

Identical steps, with debian in both URLs and $VERSION_CODENAME as the suite. Drop docker-compose-v2 from the removal loop; Debian has no such package. The key and its fingerprint are the same:

sudo curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
# in docker.sources:
URIs: https://download.docker.com/linux/debian
Suites: $(. /etc/os-release && echo "$VERSION_CODENAME")

That expands to bookworm on Debian 12 and trixie on Debian 13. Docker publishes packages for both.

AlmaLinux 9

AlmaLinux ships Podman and Buildah, which clash with Docker's packages. Remove them, add Docker's RHEL repository and install:

sudo dnf remove -y podman buildah runc
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
sudo dnf install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker

dnf asks you to accept the GPG key on first install. It should be 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35, a different key from the Debian one; check before typing y. Unlike the Debian family, the service is not started automatically, hence the last line.

Check it works

sudo docker run --rm hello-world
docker compose version
docker buildx version
docker info --format '{{.CgroupVersion}} {{.Driver}}'

On a fresh install of Engine 29 or later, expect 2 overlayfs: new installs use the containerd image store and its overlayfs snapshotter. A host upgraded from an older engine keeps overlay2. Note it is docker compose, a CLI plugin, not the old Python docker-compose binary. If a script calls the hyphenated form, update the script rather than installing the legacy tool.

Should I add my user to the docker group?

Only if that user would get full sudo anyway. Anyone in the docker group can run docker run -v /:/host and edit any file on the server as root, with no password prompt and nothing in the sudo log. It is a convenience for admins, never a way to give limited access.

sudo usermod -aG docker deploy   # then log out and back in

For service accounts that only need to run containers, consider rootless mode, below.

Rootless Docker

Rootless mode runs a separate daemon as an ordinary user, so a container escape lands in that user's account rather than as root. On Ubuntu and Debian, the docker-ce-rootless-extras package comes in with docker-ce; add uidmap and run the setup tool as the target user:

sudo apt-get install -y uidmap
sudo loginctl enable-linger app
sudo -iu app
dockerd-rootless-setuptool.sh install
echo 'export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock' >> ~/.bashrc

enable-linger keeps the user's daemon running after logout and starts it at boot. Trade-offs: binding ports below 1024 needs net.ipv4.ip_unprivileged_port_start lowered, network throughput is somewhat lower, and the user's images live in their home directory, not /var/lib/docker.

Cap log size in daemon.json

By default the json-file driver keeps every line a container ever printed. Set a ceiling before you start real workloads:

sudo tee /etc/docker/daemon.json >/dev/null <<'EOF'
{
  "log-driver": "json-file",
  "log-opts": { "max-size": "10m", "max-file": "3", "compress": "true" }
}
EOF
sudo systemctl restart docker

That allows about 30 MB per container. Staying on json-file keeps tools that read /var/lib/docker/containers/*/*-json.log directly, such as some log shippers, working. Existing containers keep their old settings until recreated.

Why does Docker bypass my firewall?

Publishing a port with -p 8080:80 makes Docker add a DNAT rule in the nat table. Incoming packets are rewritten to the container's address before filtering, so they travel the FORWARD path and never meet the INPUT chain where ufw and most nftables rulesets live. Your "deny all" rule simply isn't consulted.

On current Ubuntu and Debian releases, Docker's rules use iptables-nft, so you can see them with sudo nft list ruleset alongside your own. There are three fixes, best first.

1. Bind to localhost. Put a reverse proxy on the host and publish containers only on the loopback address:

docker run -d -p 127.0.0.1:8080:80 nginx:1.27

Or make it the default for every container by adding "ip": "127.0.0.1" to daemon.json. You then have to ask for 0.0.0.0 explicitly when you really mean public.

2. Filter in DOCKER-USER. Docker evaluates this chain before its own forwarding rules and never flushes it. To allow a published port only from one address (find your interface name with ip -br link; on VMware it is often ens192):

sudo iptables -I DOCKER-USER -i ens192 -p tcp \
  -m conntrack --ctorigdstport 8080 --ctstate NEW \
  ! -s 203.0.113.50 -j DROP

Match on --ctorigdstport because by this point the destination port has already been rewritten to the container's. These rules don't survive a reboot on their own; reapply them from a oneshot systemd unit ordered After=docker.service. The nftables guide covers keeping a ruleset that coexists with Docker's tables.

3. Use the portal firewall as an outer wall. Rules set per VM in the portal apply before traffic reaches the server, so Docker cannot override them. Allow 22, 80 and 443, deny the rest, and a forgotten -p 5432:5432 stays private.

Sizing

Docker itself adds little overhead; plan for your containers plus image storage. Boost (2 vCPU, 4 GB, 40 GB NVMe, $8/mo) handles a typical app, database and cache. Image-heavy hosts and CI builders fill disk first, so look at Turbo with 80 GB. Prices are in US dollars; your bank handles conversion.

Questions

Is docker.io from Ubuntu's archive good enough?

For a laptop, often. On a server you want Docker's repository: newer engine releases, the docker compose plugin under its current name, and fixes without waiting for the distribution to backport them. Don't mix the two; remove docker.io first.

Does Docker work on a VMware virtual machine?

Yes. FastNode servers are full VMware vSphere virtual machines with their own kernel, so Docker, containerd, cgroups v2 and overlay2 storage behave exactly as on bare metal. Nested virtualisation is not needed for containers.

Will the portal firewall block a port Docker has published?

Yes. The portal's per-VM firewall filters traffic before it reaches your server, so Docker's iptables rules cannot override it. It makes a sound outer layer, but keep the localhost binding as well so a rule change in one place doesn't expose a database.

Do I need IPv6 settings in daemon.json?

No. FastNode servers have a single IPv4 address and no IPv6, so leave Docker's IPv6 options at their defaults, which are off.