On a freshly cloned Rocky Linux 10 cloud VM, Docker Engine installed without a single warning and then refused to start. The cause was a kernel mismatch: the install pulled kernel modules for the newest kernel installed on disk while the VM still ran an older one, so the netfilter module Docker needs for its NAT rules wasn’t there. A reboot fixed it, and so did installing the matching modules, but nothing in the install output tells you that you need either.
This guide shows how to install Docker on Rocky Linux 10 and AlmaLinux 10 from Docker’s RHEL repository, get past that first start, turn on SELinux labels, and keep published ports from slipping past firewalld. We tested the steps on Rocky Linux 10.2 in October 2026 with Docker Engine 29.9.1 and Compose 5.6.0, and for AlmaLinux 10.2 we only checked package resolution in a container.
Prepare the server
Docker itself needs little, so size the server for the containers you will run on it. A fresh Docker 29 install keeps images in the containerd image store under /var/lib/containerd, which held 360 MB after this lab while /var/lib/docker held 172 KB, so that’s the path to give disk space to. You need:
- Rocky Linux 10 or AlmaLinux 10, 64-bit
- a user with sudo rights
- outbound HTTPS to download.docker.com and Docker Hub
- about 450 MB free for the packages, plus room for images
vimfor the edit steps (the cloud image has onlyvi)
Our VM had 2 vCPU, 4 GB of RAM and a 40 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 quick part, and one of the Docker books we recommend covers images, layers and networking faster than error messages will. Running Docker on more than one distro family? The multi-distro Docker install guide puts Ubuntu, Debian and Fedora side by side with this one. Our Rocky cloud image shipped only vim-minimal, which provides vi but no vim command, so install the full editor before you start:
sudo dnf install -y vim-enhanced
That pulled four packages on our VM. If you prefer vi or nano, use it wherever this guide says vim.
Remove packages that conflict
Docker’s RHEL instructions tell you to remove Podman and runc before installing, since containerd.io bundles its own runc. Decide first whether you need Podman, because we found the removal is no longer forced on Rocky 10. With Podman 5.8.2 installed, a dry run of the Docker install resolved nine packages with no conflict, and Podman installed later next to Docker still ran containers, because it uses its own runtime and runc was never installed. On a dedicated Docker server we’d still follow Docker’s instruction, since one runtime is easier to support, and that’s the command below. Removing Podman took 18 packages with it on our VM.
sudo dnf remove -y docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine podman runc
On a workstation where you use Podman, drop podman runc from the end and remove only the old Docker names. With none of them installed, dnf answered “No packages marked for removal” and “Nothing to do”, so the command is safe on a fresh host.
Add the Docker repository
Rocky Linux and AlmaLinux 10 still ship dnf 4, so the classic config-manager --add-repo syntax works here, unlike Docker on Fedora, where dnf5 rejects it. The plugin that provides config-manager was already on our cloud image, but install it first on a minimal build where it may be missing.
sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/rhel/docker-ce.repo
Use the rhel path, not centos. Docker’s install docs list the centos repo for CentOS Stream only, while the rhel repo’s $releasever resolves to 10 on both rebuilds. You should end up with this file in /etc/yum.repos.d/.
[docker-ce-stable]
name=Docker CE Stable - $basearch
baseurl=https://download.docker.com/linux/rhel/$releasever/$basearch/stable
enabled=1
gpgcheck=1
gpgkey=https://download.docker.com/linux/rhel/gpg
If gpgcheck is 0 in your copy, another script wrote it, so delete the file and add the repo again.
Install Docker Engine on Rocky Linux 10
Install the engine, CLI, containerd and both plugins in one transaction, so every piece comes from the same repo snapshot and the versions match.
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
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, the value on Docker’s RHEL install page. With -y the key is accepted without a prompt, so either drop -y on this first install and compare before you answer, or read the imported key back afterwards with rpm -qi gpg-pubkey-621e9f35-*, which named “Docker Release (CE rpm)” on our VM. Anything else means you are not talking to Docker’s repo. Look at the dependencies in this excerpt before you move on, because they explain the next section.
Installing:
containerd.io x86_64 2.4.2-2.el10 docker-ce-stable 35 M
docker-buildx-plugin x86_64 0.38.0-1.el10 docker-ce-stable 18 M
docker-ce x86_64 3:29.9.1-1.el10 docker-ce-stable 26 M
docker-ce-cli x86_64 1:29.9.1-1.el10 docker-ce-stable 8.9 M
docker-compose-plugin x86_64 5.6.0-1.el10 docker-ce-stable 8.4 M
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
You get twelve packages, 148 MB, and on our VM the install took 64 seconds, including an initramfs rebuild for the new modules. The Rocky 10 cloud image leaves out kernel-modules and kernel-modules-extra, and Docker needs netfilter modules from them, so dnf adds both. On our VM it picked the build for the newest kernel on disk, which was not the kernel the VM was running. Note that kernel-modules-extra, the package that holds the module Docker needs, arrives only as a weak dependency, so a host with install_weak_deps=False in /etc/dnf/dnf.conf skips it.
Start Docker and fix the addrtype error
Docker’s RPMs install the service disabled and stopped. Before you start it, compare the running kernel with the modules dnf just installed, because that decides whether the first start works.
uname -r
rpm -q kernel-modules-extra
If rpm prints “package kernel-modules-extra is not installed”, weak dependencies are off on your host, so install it yourself and then compare again:
sudo dnf install -y kernel-modules-extra
Ours did not match. The VM ran 6.12.0-211.16.1.el10_2.0.1, an older kernel left over from the image, while the modules were built for 6.12.0-211.66.1.el10_2, a newer kernel already installed but not yet booted. If yours differ the same way, starting Docker fails.
sudo systemctl enable --now docker
systemd reports “Job for docker.service failed because the control process exited with error code”, and the journal names the module:

The two version strings at the top are the whole story: the running kernel and the module package disagree, and the iptables warning at the bottom is what that disagreement looks like to Docker.
Error: “Extension addrtype revision 0 not supported, missing kernel module?”
Docker builds its bridge network by adding NAT rules that match on address type, which needs the xt_addrtype module. That module ships in kernel-modules-extra, and the copy dnf installed belonged to the other kernel, so iptables could not load it and the daemon quit with “Error initializing network controller”. You have two fixes, and we tested both on the same VM, the module install first and the reboot afterwards (the newer kernel already had its own modules from the Docker install):
- reboot into the newest kernel: one command, plus a reboot window
- install modules for the running kernel: no reboot, 45 MB of downloads
The reboot is the one you want on most servers. After it, Docker started on its own because the service was already enabled.
sudo systemctl reboot
If you cannot reboot yet, install the module packages built for the kernel you’re running instead. We needed both: with only kernel-modules installed, modprobe xt_addrtype still failed with “Module xt_addrtype not found”.
sudo dnf install -y "kernel-modules-$(uname -r)"
sudo dnf install -y "kernel-modules-extra-$(uname -r)"
sudo systemctl reset-failed docker
sudo systemctl restart docker
reset-failed clears systemd’s restart counter, because the failed starts may have hit the start limit. This route only works while the running kernel’s packages are still in the BaseOS repo. Ours was a current 10.2 kernel; if your image kernel comes from an older minor release, dnf answers “No match for argument” because those packages moved to the vault, and the reboot is your only fix. Plan the reboot anyway, since the next kernel update brings the same mismatch back if the box never boots into it.
Verify the install
Run hello-world once the service is active. It tests the whole chain, from CLI to daemon to Docker Hub to containerd, so one green result rules out the common failures:
sudo docker run --rm hello-world
You should see “Hello from Docker!” after the pull. “Cannot connect to the Docker daemon” means the service is still down, so go back to the journal; a pull timeout means the daemon is up but cannot reach Docker Hub. Then check the engine version and the storage backend.
sudo docker version --format 'Client {{.Client.Version}} / Server {{.Server.Version}}'
sudo docker info | grep -E 'Storage Driver|driver-type|Cgroup Version|Firewall Backend'
A fresh install reports containerd’s overlayfs snapshotter and cgroup v2, and the firewall backend reads iptables until firewalld enters the picture. This excerpt is the info grep from our VM before any hardening:
Storage Driver: overlayfs
driver-type: io.containerd.snapshotter.v1
Cgroup Version: 2
Firewall Backend: iptables
The screenshot is the same host at the end of the lab, after the reboot and the hardening below.

Client and server match at 29.9.1, the daemon lists SELinux, docker0 sits in its own firewalld zone and SELinux is still enforcing. The next three sections get you there.
Run Docker as your user
Only root can use /var/run/docker.sock by default, so docker ps as your user fails with “permission denied while trying to connect to the docker API”. The docker group fixes that, at a price you should accept knowingly: its members can start a container that mounts the host’s root filesystem, which makes the group root-equivalent. Add only people who already have sudo.
sudo usermod -aG docker $USER
newgrp docker
newgrp applies to the current shell. Log out and back in so new sessions get the group, then run docker ps; an empty table header means it worked.
Turn on SELinux labels for containers
Rocky Linux enforces SELinux, but Docker’s daemon ships with its own SELinux support off. On our install docker info listed only seccomp and cgroupns, and a container read a file under our home directory with no relabel at all, which tells you SELinux was not confining it. Podman labels containers by default; Docker needs one key. Before you change it, create a page under your home directory and read it from a container, so you have a before and after to compare. This read worked on our VM with the default daemon.
mkdir -p ~/site && echo '<h1>served from the host</h1>' > ~/site/index.html
sudo docker run --rm -v ~/site:/data:ro alpine cat /data/index.html
Now open the daemon config.
sudo vim /etc/docker/daemon.json
Add the settings below, merging them into any existing file. Besides SELinux, they switch logging to the rotating local driver, because json-file never rotates, and turn on live restore, so a daemon restart or a patch upgrade within the same minor release does not stop running containers (Docker does not support it across minor or major upgrades):
{
"selinux-enabled": true,
"log-driver": "local",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"live-restore": true
}
A JSON error stops the daemon from starting, so validate the file before the restart. json.tool echoes the file when it parses and names the bad line when it does not.
python3 -m json.tool /etc/docker/daemon.json
sudo systemctl restart docker
sudo docker info --format '{{.SecurityOptions}} {{.LoggingDriver}} {{.LiveRestoreEnabled}}'
The output should now include SELinux, the local driver and true. If you see the old options, the restart did not pick up the file.
[name=seccomp,profile=builtin name=selinux name=cgroupns] local true
From here, bind mounts need a relabel. Run the same read again and it fails with “cat: can’t open ‘/data/index.html’: Permission denied”.
sudo docker run --rm -v ~/site:/data:ro alpine cat /data/index.html
sudo ausearch -m AVC -ts recent -i
The audit log shows the denial, trimmed here to the identifying fields:
avc: denied { read } for pid=73546 comm=cat name=index.html dev="sda4" ino=75502352 scontext=system_u:system_r:container_t:s0:c127,c643 tcontext=unconfined_u:object_r:user_home_t:s0 tclass=file permissive=0
The container runs as container_t with a private category pair, and the directory still carries user_home_t. Add :Z to the volume flag and Docker relabels it to container_file_t for that container, after which the read works.
sudo docker run --rm -v ~/site:/data:ro,Z alpine cat /data/index.html
Use lowercase :z when several containers share one directory. Never relabel /home, /etc or /var themselves, because the host services that read them would start failing, and never set SELinux to permissive to make a mount work.
Keep published ports behind firewalld
If you publish a port, Docker rewrites its destination in the nat table and forwards it to the container, so a -p port is open to the network even when the public zone has no rule for it. With firewalld running, Docker also puts docker0 in its own docker zone with a forwarding policy. Rocky Linux server installs run firewalld; the cloud image we used had none, so we installed it and restarted Docker so it could register with firewalld.
Install firewalld but leave it stopped for a moment, because the public zone it starts with allows only ssh, cockpit and dhcpv6-client.
sudo dnf install -y firewalld
If sshd listens on a port other than 22, new logins on that port fail once firewalld starts, so open it while the service is still stopped. firewall-offline-cmd writes the saved configuration directly; put your port in place of 2222. We ran exactly this order on the lab VM, and firewall-cmd --list-ports showed 2222/tcp as soon as firewalld came up.
sudo firewall-offline-cmd --add-port=2222/tcp
Start firewalld, restart Docker so it registers with it, and check where docker0 landed.
sudo systemctl enable --now firewalld
sudo systemctl restart docker
sudo firewall-cmd --get-zone-of-interface=docker0
Check that last command’s output, because we got “no zone” both times we ran this sequence. The first time, the next restart (made after we had switched live restore off) created the docker zone and the docker-forwarding policy. The second time, on a freshly installed firewalld with live restore on, our immediate retry failed with “start of the service was attempted too often”, because Docker’s unit allows three starts per minute and we had used them up. After clearing the counter, the restart created the zone with live restore still on, so live restore is not the cause. If you see “no zone”, run the same recovery:
sudo systemctl reset-failed docker && sudo systemctl restart docker
The zone query then printed docker and docker info reported the iptables+firewalld backend.
To see what that means for exposure, we published nginx on 8080, bound a second nginx to loopback on 8082, and ran a Python web server on 8081 with no firewall rule for any of them. From another machine on the LAN, we requested each port with curl -s -o /dev/null -w "%{http_code}", and only Docker’s public port answered:
remote 8080: 200
remote 8081: 000
remote 8082: 000
firewalld refused the host service as it should (curl’s 000 means no HTTP response, whether the packet was rejected or dropped), and it did nothing about the Docker port. The public zone only listed cockpit, dhcpv6-client and ssh, so reading firewall-cmd --list-all tells you nothing about what Docker exposes. Bind anything private to loopback and put a reverse proxy in front. This is the 8082 container from the test.
sudo docker run -d --name web -p 127.0.0.1:8082:80 nginx:alpine
On the host, curl localhost:8082 returned 200, while the LAN request above got no response. Remove the test container to clean up:
sudo docker rm -f web
Leave plain -p 8080:80 for services you mean to publish. For zones, rich rules and the rest of the host’s policy, see our firewalld guide for RHEL-family systems.
Run a Compose stack
Compose ships as a CLI plugin, so docker compose (with a space) is ready to use. A one-service stack that serves a host directory proves the plugin, the SELinux relabel and the loopback port together. Create the project directory, a page for nginx to serve and the Compose file.
mkdir -p ~/app/html && cd ~/app
echo '<h1>Docker on Rocky Linux</h1>' > ~/app/html/index.html
vim ~/app/compose.yaml
Paste the file we ran. The :Z and the 127.0.0.1 bind carry over the two previous sections:
services:
app:
image: nginx:alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro,Z
Start it and fetch the page. --wait returns only when the container is up, which makes the curl straight after it reliable in a script:
docker compose up -d --wait
curl -s localhost:8080/
docker compose down
curl printed <h1>Docker on Rocky Linux</h1> and docker compose ps showed 127.0.0.1:8080->80/tcp. For multi-service files, health checks and env files, the Docker Compose guide goes further.
Install Docker on AlmaLinux 10
AlmaLinux 10 uses the same repo and the same commands, and resolves the same Docker builds; Docker’s own docs name RHEL 8, 9 and 10 and do not mention the rebuilds, which is why we checked. We checked package resolution in an AlmaLinux 10.2 container: dnf 4.20.0 accepted --add-repo and resolved docker-ce 29.9.1, containerd.io 2.4.2 and Compose 5.6.0 from the same el10 builds Rocky got, with a newer container-selinux (2.248.0 against Rocky’s 2.246.0). A container image starts without most base packages, so it resolved 29 packages instead of 12, and with no kernel installed it pulled no kernel modules. That is the one part we could not check there. On an AlmaLinux VM, run the uname -r and rpm -q kernel-modules-extra comparison from the start section before you start Docker, because the same mismatch can happen on any image that ships an installed but unbooted kernel.
Script it for more Rocky and Alma hosts
Everything above turns into a short, repeatable sequence once you know where it breaks. On each new Rocky or AlmaLinux 10 host, in this order:
- Remove old Docker packages, plus Podman and runc on dedicated Docker servers.
- Add the rhel repo with
dnf config-manager --add-repo. - Install the five packages and check the GPG fingerprint once.
- Compare
uname -rwithkernel-modules-extraand reboot if they differ. - Write
/etc/docker/daemon.json, validate it, then enable and start Docker. - Confirm
docker0sits in thedockerfirewalld zone. - Publish private services on
127.0.0.1only.
Step 4 is the one automation misses, because the install succeeds and the failure only shows at start. An Ansible role or a cloud-init script should reboot unconditionally after the install, or compare the two versions and reboot when they differ. Step 6 needs a recheck after firewalld is added to an existing Docker host, since the restart right after firewalld started did not create the zone. Do both, and your Rocky hosts should behave the way our lab VM did; test the first AlmaLinux VM by hand before you trust the role there.