Every mainstream Linux distro runs the same Docker Engine build, as long as you install it from Docker’s own repository rather than the distro archive. The archive copy lags: on Ubuntu 24.04 the docker.io package sat eight minor releases behind docker-ce the week we tested, and its Compose plugin trails just as far. So the method is the same on every family: add Docker’s signed repo, install five packages, start the daemon, then do the post-install steps most guides skip.
This page covers how to install Docker on Linux across Ubuntu and Debian, Rocky Linux, AlmaLinux and RHEL, and Fedora, then non-root access, rootless mode, log rotation and the firewall gap that published ports open. Every command here was run on Ubuntu 26.04, Debian 13, Rocky Linux 10.2 and Fedora 45 in October 2026 with Docker Engine 29.9.1 and Compose 5.6.0.
Choose how to install Docker
Install from Docker’s official repository on any machine you plan to keep, because it’s the only method that gets you current releases through your normal package updates. Security fixes then land with a plain apt upgrade or dnf upgrade, so nothing on the host drifts out of date unnoticed. The other routes each have a narrower job:
- Official repo (
download.docker.com): servers, workstations, anything long-lived - Convenience script (
get.docker.com): throwaway VMs and CI runners only - Distro package (
docker.io,moby-engine): when policy forbids third-party repos - Docker Desktop: a laptop where you want the GUI and a VM wrapper, never a server
The repo path and package manager change per family, but the package set does not, which is why one page can cover all of them. Every distro below installs the same five packages:
docker-ce, the engine and its systemd unitsdocker-ce-cli, thedockerclientcontainerd.io, the runtime with runc bundleddocker-buildx-plugin, the BuildKit front enddocker-compose-plugin, which providesdocker compose
Only the repo URL differs. Here is where each family points, with the dedicated guide when you want more depth on one distro:
| Distro | Repo path | Package manager | Dedicated guide |
|---|---|---|---|
| Ubuntu 26.04 / 24.04 / 22.04 | linux/ubuntu | apt (deb822 source) | Docker CE on Ubuntu 26.04 |
| Debian 13 / 12 | linux/debian | apt (deb822 source) | Docker CE on Debian |
| Rocky Linux, AlmaLinux, RHEL 10 / 9 / 8 | linux/rhel | dnf 4 | Docker CE on Rocky and AlmaLinux |
| Fedora 45 / 44 / 43 | linux/fedora | dnf5 | Docker on Fedora |
Linux Mint and other Ubuntu derivatives use the Ubuntu repo, and the codename line below already picks Mint’s Ubuntu base. Mint’s reasons and errors are covered in the Linux Mint Docker and Compose guide. openSUSE was not part of this lab, and its packages are covered in the openSUSE Leap Docker and Podman guide.
Check what the server needs
Docker Engine itself is light, so the sizing that matters comes from the containers you plan to run, not from Docker. What grows is /var/lib/docker: every image layer, volume and container log lands there. A small WordPress, MariaDB and Redis stack we ran in the same lab already took about 1.7 GB of images and 245 MB of volumes before a single post was written. Size the disk for your images plus data, not for the engine. Before you start, have:
- a 64-bit supported release (x86_64 or arm64)
- a user with sudo rights
- outbound HTTPS to
download.docker.comand Docker Hub - about 400 MB free for the packages, plus room for images under
/var/lib/docker
Our lab VMs ran 2 vCPU, 4 GB of RAM and a 30 GB disk, which is a floor for following along, not a production recommendation. If containers are new to you, getting the engine running is the easy hour. Learning how images, layers and networking behave takes longer, and one of the Docker books we recommend covers that faster than piecing it together from error messages.
Install Docker on Ubuntu and Debian
Ubuntu and Debian use the same steps with a different repo path, so set it once. Use ubuntu on Ubuntu and debian on Debian:
export DISTRO=ubuntu
Remove any distro-packaged Docker first, because the archive’s Docker, containerd and runc packages conflict with Docker’s own containerd.io. The command only removes what is installed, so it is safe on a fresh box, and on a host that already ran docker.io it keeps /var/lib/docker and your images:
sudo apt remove -y $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc 2>/dev/null | cut -f1)
Next, fetch Docker’s signing key into its own keyring file. Keeping it out of the global trusted keyring means this key can only sign packages from Docker’s repo:
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/${DISTRO}/gpg" -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
Now write the source in the deb822 format Docker’s docs use today. The codename comes from /etc/os-release, preferring UBUNTU_CODENAME and falling back to VERSION_CODENAME, which gives resolute on Ubuntu 26.04, noble on 24.04 and trixie on Debian 13. Reading the codename at runtime matters, because a hardcoded one breaks the moment you copy the line to another release:
printf 'Types: deb\nURIs: https://download.docker.com/linux/%s\nSuites: %s\nComponents: stable\nArchitectures: %s\nSigned-By: /etc/apt/keyrings/docker.asc\n' "${DISTRO}" "$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")" "$(dpkg --print-architecture)" | sudo tee /etc/apt/sources.list.d/docker.sources
Refresh the index and confirm apt now sees Docker’s build as the candidate. If Candidate shows a version without the ~ubuntu or ~debian suffix, the source file didn’t load, so fix that before installing or you’ll end up with the archive build again.
sudo apt update
apt-cache policy docker-ce | head -5
On the Ubuntu 26.04 box the candidate came straight from the resolute suite.
docker-ce:
Installed: (none)
Candidate: 5:29.9.1-1~ubuntu.26.04~resolute
Version table:
5:29.9.1-1~ubuntu.26.04~resolute 500
Install all five packages in one transaction, so apt resolves the engine, CLI and runtime from the same repo snapshot and they arrive as a matching set:
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Ubuntu 26.04 pulled seven packages, 102 MB to download and 388 MB on disk, in 19 seconds. Debian 13 pulled 22 packages and 495 MB because docker-ce recommends git, which drags in Perl; add --no-install-recommends if you don’t want Git on a server, and then name docker-ce-rootless-extras in the install line yourself. It’s a recommended package too and rootless mode needs it. On both, the service is enabled and running as soon as apt finishes, so you can go straight to the verify step.
Install Docker on Rocky Linux, AlmaLinux and RHEL
Rocky Linux and AlmaLinux use Docker’s rhel repo, since $releasever resolves to the same major version the RHEL builds target. Docker also publishes a centos repo, but its docs only list CentOS Stream for it, so we stayed on the RHEL path that matches the rebuilds. Remove Podman and its runc first if they’re installed, because Docker’s packages conflict with them on RHEL-family systems.
sudo dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine podman runc
Add the repo with the dnf 4 config-manager plugin. Rocky 10 already had the plugin installed, so the first command was a no-op on our VM, but a minimal image without it can’t run the second command:
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
Then install the same five packages. dnf imports Docker’s GPG key on the first run and prints its fingerprint, which must read 060A 61C5 1B55 8A7F 742B 77AA C52F EB6B 621E 9F35. Anything else means you are not talking to Docker’s repo, so stop there:
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Rocky’s transaction is the heaviest of the four distros. Besides the SELinux policy and the nftables tooling, it pulls the full kernel modules package that the minimal cloud image leaves out:
Installing dependencies:
container-selinux noarch 4:2.246.0-1.el10 appstream 57 k
iptables-nft x86_64 1.8.11-16.el10_2 appstream 182 k
kernel-modules x86_64 6.12.0-211.66.1.el10_2 baseos 42 M
libnftnl x86_64 1.3.0-3.el10 baseos 87 k
nftables x86_64 1:1.1.5-6.el10_2 baseos 447 k
Installing weak dependencies:
docker-ce-rootless-extras x86_64 29.9.1-1.el10 docker-ce-stable 5.6 M
kernel-modules-extra x86_64 6.12.0-211.66.1.el10_2 baseos 3.1 M
Transaction Summary
================================================================================
Install 12 Packages
Total download size: 148 M
Installed size: 429 M
Those kernel modules are built for the newest kernel in the repo, not necessarily the one you are running. Our cloud VM was still on an older kernel, so we rebooted before starting Docker to keep the netfilter modules and the running kernel in step. If uname -r does not match the version in that transaction, reboot first.
Error: “Key import failed (code 2)” and “GPG check FAILED”
Our first install on a freshly cloned Rocky VM failed with a GPG error that had nothing to do with the key. The real line is the one above it, about the RPM transaction lock:
error: can't create transaction lock on /usr/lib/sysimage/rpm/.rpm.lock (Resource temporarily unavailable)
Key import failed (code 2). Failing package is: containerd.io-2.4.2-2.el10.x86_64
GPG Keys are configured as: https://download.docker.com/linux/rhel/gpg
...
Error: GPG check FAILED
cloud-init was running a first-boot dnf -y upgrade in the background, which held the lock, so the key import couldn’t write to the RPM database. Check for another package manager with pgrep -a dnf, wait for it to finish, then rerun the install. The downloaded packages stay in the dnf cache, so the second attempt is quick.
Install Docker on Fedora
Fedora ships dnf5, and its config-manager takes a different syntax: addrepo --from-repofile instead of the dnf 4 --add-repo, so don’t copy the RHEL command across. Unlike RHEL, Docker’s Fedora docs do not ask you to remove Podman, and on Fedora 45 the two installed side by side; we ran a container with each afterwards:
sudo dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-selinux docker-engine-selinux docker-engine
sudo dnf config-manager addrepo --from-repofile https://download.docker.com/linux/fedora/docker-ce.repo
Install the packages. The GPG fingerprint dnf prints is the same one shown in the Rocky section, so check it the same way before you trust the repo:
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Fedora 45 needed nine packages, 102 MiB to download and 387 MiB installed, and it pulled iptables-legacy rather than the nft variant Rocky got. container-selinux was already present because Podman depends on it.
Start Docker and verify the install
RPM-based distros install Docker disabled, so on Rocky, AlmaLinux, RHEL and Fedora the daemon doesn’t exist as a running process until you start it. Debian and Ubuntu already did this during install, so running the command there again changes nothing and is safe to keep in a script that covers both families:
sudo systemctl enable --now docker
Run the hello-world image to prove the whole chain works. The CLI has to reach the daemon, the daemon has to pull from Docker Hub, and containerd has to start a container, so one command tests all three.
sudo docker run --rm hello-world
A healthy install prints the greeting after the pull. A “Cannot connect to the Docker daemon” message means the service isn’t running, so check systemctl status docker. A pull timeout points at DNS or a proxy instead, because the daemon is up but can’t reach Docker Hub.
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
4f55086f7dd0: Pull 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.
Then check the versions. The engine and client should match, and the Compose plugin answers to docker compose with a space:
sudo docker version
sudo docker compose version
All four distros reported the same engine, containerd and runc builds, which is the point of using Docker’s repo. This is the server half of the Ubuntu 26.04 output; the client block above it reported the same version.
Server: Docker Engine - Community
Engine:
Version: 29.9.1
API version: 1.56 (minimum version 1.40)
Go version: go1.26.9
Git commit: b26fb77
Built: Wed Oct 14 18:32:35 2026
OS/Arch: linux/amd64
Experimental: false
containerd:
Version: v2.4.2
GitCommit: b7a25261e029d68978bbf290df0a62fc819b3c51
runc:
Version: 1.5.2
GitCommit: v1.5.2-0-g29dd3dc2
docker-init:
Version: 0.19.0
GitCommit: de40ad0
Docker Compose version v5.6.0
On Rocky Linux 10 with SELinux enforcing, the package list and service state looked like this after the reboot:

One SELinux detail surprises people coming from Podman. Docker’s daemon runs with its own SELinux support turned off by default, so docker info on Rocky lists only seccomp and cgroupns under Security Options. A read-only bind mount from our home directory worked without the :Z relabel flag. That keeps bind mounts simple, but it also means SELinux is not confining your containers the way it confines Podman’s, so treat the daemon as the trust boundary. To have Docker apply SELinux labels to containers, add "selinux-enabled": true to /etc/docker/daemon.json and restart the daemon; we did not test that mode, and once it is on, bind mounts need the :z or :Z relabel suffix.
Skip the convenience script on servers
Docker’s get.docker.com script detects the distro and runs the repo setup for you, which is handy on a VM you’ll delete tonight and a poor fit for anything you maintain. Docker’s own docs say it is not meant for production, is not designed to upgrade an existing install, and installs the latest major release without asking. The dry run shows exactly what it would do, so read that before you pipe anything into a root shell:
curl -fsSL https://get.docker.com -o get-docker.sh
sudo sh ./get-docker.sh --dry-run
On Ubuntu 26.04 the plan differs from the manual steps in two ways that matter. It writes a one-line docker.list instead of the deb822 file, and it also installs docker-model-plugin, which the manual package list leaves out. Neither is wrong, but both mean the host no longer matches what the docs and this page describe.
# Executing docker install script, commit: ada7a567e59b3935bd601bb21e422da50e80e376
apt-get -qq update >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get -y -qq install ca-certificates curl >/dev/null
install -m 0755 -d /etc/apt/keyrings
curl -fsSL "https://download.docker.com/linux/ubuntu/gpg" -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu resolute stable" > /etc/apt/sources.list.d/docker.list
apt-get -qq update >/dev/null
DEBIAN_FRONTEND=noninteractive apt-get -y -qq install docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin docker-model-plugin >/dev/null
systemctl enable --now docker.service 2>/dev/null
Mixing the two methods on one host leaves you with both docker.list and docker.sources pointing at the same repo, so pick one per machine. If you already ran the script, delete docker.list before adding the deb822 source, because apt reads both files. For CI runners, the script with --dry-run reviewed once is fine; for servers, use the manual steps.
Run Docker without sudo
By default only root can talk to /var/run/docker.sock, so a plain docker ps as your user fails with this message on Docker 29:
permission denied while trying to connect to the docker API at unix:///var/run/docker.sock
Adding your user to the docker group fixes that. Be clear about the cost: membership in that group is root-equivalent, because anyone in it can start a container that mounts / from the host. Only add accounts you’d trust with sudo, and use rootless mode in the next section when you can’t:
sudo usermod -aG docker $USER
newgrp docker
newgrp applies the group to the current shell only. Log out and back in for every other session to pick it up, because group membership is read only at login. Then run docker ps without sudo, and an empty table header means it worked.
Set up rootless Docker
Rootless mode runs a separate daemon as your own user, so a container breakout lands in an unprivileged account instead of root. The docker-ce-rootless-extras package that came with the install provides the setup tool, but on Ubuntu 26.04 it refused to run twice before it worked, and both errors are worth knowing. The first one happens whenever the system daemon is still up:
[ERROR] Aborting because rootful Docker (/var/run/docker.sock) is running and accessible. Set --force to ignore.
If this machine should run only rootless Docker, stop and disable the system daemon and its socket, then remove the stale socket file:
sudo systemctl disable --now docker.service docker.socket
sudo rm -f /var/run/docker.sock
The second error is a missing newuidmap binary. Rootless Docker maps your user onto a range of subordinate UIDs, and the Ubuntu cloud image does not ship the uidmap package that provides the mapping helpers. Install it on Debian or Ubuntu, and confirm your user owns a subordinate range of at least 65,536 IDs:
sudo apt install -y uidmap
grep ^$(whoami): /etc/subuid /etc/subgid
Each file should show one line with a start ID and a count of 65536. Now run the setup tool as your normal user, not with sudo:
dockerd-rootless-setuptool.sh install
It installs a systemd user unit, creates a rootless CLI context and switches to it. The tail of the output tells you how to keep it running after logout:
[INFO] Installed docker.service successfully.
[INFO] To control docker.service, run: `systemctl --user (start|stop|restart) docker.service`
[INFO] To run docker.service on system startup, run: `sudo loginctl enable-linger josphat`
[INFO] Creating CLI context "rootless"
Successfully created context "rootless"
[INFO] Using CLI context "rootless"
Current context is now "rootless"
Without linger, systemd stops your user daemon and every container with it the moment your last session closes. Enable it for your account so the daemon also starts at boot:
sudo loginctl enable-linger $USER
The active context and the security options confirm you’re talking to the rootless daemon, and the package list shows where every component came from:

The one limit you’ll hit first is ports below 1024. A rootless daemon cannot bind them, so -p 80:80 fails with a message that names the fix.
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint jolly_cohen (c90ab22ec70457485a5adfb444dae1ad2f069dc25aaa768acfc05b0acce9fb98): error while calling RootlessKit PortManager.AddPort(): cannot expose privileged port 80, you can add 'net.ipv4.ip_unprivileged_port_start=80' to /etc/sysctl.conf (currently 1024), or set CAP_NET_BIND_SERVICE on rootlesskit binary, or choose a larger port number (>= 1024): listen tcp4 0.0.0.0:80: bind: permission denied
Publishing on 8080 worked straight away and returned HTTP 200 from nginx. The sections after this one assume the system daemon. A rootless daemon reads ~/.config/docker/daemon.json and restarts with systemctl --user restart docker, and RootlessKit binds published ports as ordinary sockets, so the firewall-bypass behaviour below applies to the system daemon only. We would keep rootless containers on high ports and put a reverse proxy on 80 and 443, rather than lowering the unprivileged port floor for every user on the box.
Rotate container logs
Docker’s default json-file log driver doesn’t rotate by default, so a chatty container slowly fills /var/lib/docker until the host runs out of disk. Switch the daemon default to the local driver, which compresses and rotates, and cap each container at three 10 MB files. A single tee writes /etc/docker/daemon.json:
printf '{\n "log-driver": "local",\n "log-opts": {\n "max-size": "10m",\n "max-file": "3"\n }\n}\n' | sudo tee /etc/docker/daemon.json
sudo systemctl restart docker
Confirm the daemon picked up the new default. It should print local, and a failed restart instead means the JSON has a typo:
sudo docker info --format '{{.LoggingDriver}}'
The new default only applies to containers created after the restart, so existing ones keep the driver they started with until you recreate them. Start a throwaway container that prints the date every second, so there is something to inspect:
sudo docker run -d --name logtest alpine sh -c 'while true; do date; sleep 1; done'
Then read its log settings.
sudo docker inspect logtest --format '{{.HostConfig.LogConfig}}'
The driver and both limits show up in the container’s config. If you still see json-file here, the container predates the restart or the daemon didn’t read the file.
{local map[max-file:3 max-size:10m]}
Remove the test container once you’ve seen the result:
sudo docker rm -f logtest
If daemon.json already exists, merge these keys into it instead of overwriting the file, because a JSON syntax error stops the daemon from starting at all. Validate the file with python3 -m json.tool /etc/docker/daemon.json before the restart if you edited it by hand.
Keep published ports behind the firewall
A port you publish with -p is reachable from the network even when your host firewall has no rule for it, because Docker inserts its own NAT rules ahead of the firewall’s input chain. We proved it on Rocky Linux 10 with firewalld running in the default public zone and no port rules. A Python web server on port 8081 was blocked from another machine, while nginx published by Docker on 8080 answered:
remote 8081: 000
remote 8080 (docker -p): 200
The same happens with UFW on Ubuntu. For anything that should only be reached locally or through a reverse proxy, bind the port to loopback with -p 127.0.0.1:8080:80, which keeps it off the network regardless of firewall state. Leave a plain -p 8080:80 only for services you mean to expose.
Docker on each distro at a glance
The engine is identical everywhere, so the differences that bite are in packaging and defaults. This is what each lab VM did with the same five-package install:
| Item | Ubuntu 26.04 | Debian 13 | Rocky Linux 10 | Fedora 45 |
|---|---|---|---|---|
| Packages installed | 7 | 22 | 12 | 9 |
| Download / on disk | 102 MB / 388 MB | 121 MB / 495 MB | 148 MB / 429 MB | 102 MiB / 387 MiB |
| Extra dependencies | pigz | git, perl, nftables | container-selinux, iptables-nft, kernel-modules | iptables-legacy, libcgroup |
| Service after install | enabled, running | enabled, running | disabled | disabled |
| Mandatory access control | AppArmor | AppArmor | SELinux (daemon support off) | SELinux (daemon support off) |
| Remove Podman first | podman-docker only | podman-docker only | yes | no, coexists |
Read the last two rows before you script this across a fleet. An Ansible role that only installs packages leaves Docker stopped on every RPM host, and one that removes Podman everywhere breaks Fedora workstations that rely on it for toolbox. If you outgrow single-host Docker, Portainer is the usual next step for managing several of these hosts from one place.