SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor · Updated 2026-08-12

Install Docker on Rocky Linux or AlmaLinux

Install Docker Engine on Rocky Linux or AlmaLinux with dnf, then fix what Ubuntu guides skip: podman owning the docker command and SELinux on bind mounts.

Install Docker on Rocky Linux and AlmaLinux

To install Docker on Rocky Linux or AlmaLinux you add Docker's own dnf repository, install the engine with the compose plugin, then enable the service. That part is four commands, and it is identical on both distributions because both are rebuilds of Red Hat Enterprise Linux (RHEL) and share its package layout. CentOS Stream works the same way.

The install is short, so most of this guide covers what Enterprise Linux (EL) does differently from Ubuntu. Podman may already own the docker command on your image. SELinux blocks bind-mounted files until they carry the right label. Firewalld does not filter Docker's published ports, so a container port can be open to the internet while firewall-cmd reports that nothing is open.

Do not use Docker's convenience script from get.docker.com. Docker's own documentation says it is not recommended for production. It rewrites your repository configuration without asking, and it cannot be re-run safely to upgrade. Adding the repository by hand means dnf upgrade treats Docker like every other package on the box.

Is podman already answering the docker command?

Rocky Linux and AlmaLinux ship podman in their default repositories, and many VPS images install it for you. Some images go further and install podman-docker, which places a shell script at /usr/bin/docker that calls podman. Every docker command you type then runs podman instead, so a guide written for Docker starts producing output you did not expect.

The first tell is a banner. The /usr/bin/docker script checks for the file /etc/containers/nodocker, and when that file is missing it prints one line before it runs anything:

Emulate Docker CLI using podman. Create /etc/containers/nodocker to quiet msg.

Someone may have created that file to silence the banner, so do not rely on it alone. Ask the package database which package owns the binary:

command -v docker
rpm -qf "$(command -v docker)"

An answer starting with podman-docker means podman is answering. An answer starting with docker-ce-cli means real Docker. If rpm -qf reports that no package owns the file, someone installed it by hand and you should read the script before trusting it.

Podman runs the same OCI images and is a reasonable choice. If you want it, stop here. If you want Docker Engine, remove the conflicting packages first. This is the list Docker documents for RHEL:

sudo dnf remove docker docker-client docker-client-latest docker-common \
  docker-latest docker-latest-logrotate docker-logrotate docker-engine podman runc

Read what dnf plans to take with it before you confirm. On a fresh VPS image the list is short. On a box someone has already used, removing podman can pull out cockpit-podman or another tool that depends on it.

Keeping podman alongside Docker is possible in principle: remove only podman-docker, so the docker name is free, and runc, which the containerd.io package replaces. Docker's documentation treats podman as a conflicting package, so this layout is not one Docker supports. If the install still reports a conflict, use the full removal list above.

Add Docker's repository with dnf config-manager

Docker publishes RPMs for Enterprise Linux at download.docker.com. The repository file points at the CentOS tree, which is the one Rocky Linux and AlmaLinux resolve against. Checked in August 2026, Docker documents this repository for CentOS Stream 9 and CentOS Stream 10.

sudo dnf -y install dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo

Version 5 of dnf dropped the --add-repo argument, so that second command fails on newer releases. Check which one you have, then pick the matching form:

dnf --version

If it prints a 5.x version, use the subcommand form instead:

sudo dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/centos/docker-ce.repo

Both write the same file to /etc/yum.repos.d/docker-ce.repo. The wrong form fails with an unknown-argument error rather than doing something silently wrong, so you will not miss it.

That repo file sets baseurl to a path containing $releasever, and dnf expands that variable from your release package. Rocky Linux and AlmaLinux set it to the major version number, so 9 on EL 9 and 10 on EL 10, which is why a CentOS repository resolves correctly on a Rocky box. Confirm the expansion before you install:

sudo dnf repoinfo docker-ce-stable

Read the Repo-baseurl line. It should end in /9/x86_64/stable or /10/x86_64/stable. If your release sets $releasever to a point version such as 9.6, dnf reports Status code: 404 for that URL when it fetches metadata. Fix it by editing /etc/yum.repos.d/docker-ce.repo and replacing $releasever with the bare major number.

Install the engine and the compose plugin

sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

Five packages, and each does one job. docker-ce is the daemon, dockerd. docker-ce-cli is the docker command you type. containerd.io is the container runtime the daemon drives. docker-buildx-plugin builds images. docker-compose-plugin provides docker compose as a subcommand.

These packages do not install a hyphenated docker-compose binary. That was Compose v1, which reached end of life in July 2023. Anything that calls docker-compose with a hyphen needs updating to docker compose with a space.

The first install stops to import Docker's signing key and shows you its fingerprint. The key comes from gpgkey=https://download.docker.com/linux/centos/gpg in the repo file you just added, so compare the fingerprint dnf prints against that URL before you accept it.

One failure appears often enough to name. If dnf reports that containerd.io requires container-selinux and nothing provides it, your AppStream repository is disabled. Run dnf repolist and confirm appstream is listed, because that is where container-selinux ships on EL 9 and EL 10.

Start Docker and confirm it runs

sudo systemctl enable --now docker
sudo systemctl status docker --no-pager
sudo docker run --rm hello-world

Docker's RPM packages leave the daemon stopped and disabled after install. That is why this step appears on Docker's CentOS page and not on its Ubuntu page, where the deb starts the service for you. Skip enable and Docker runs until the next reboot, then stays down and takes every container with it.

systemctl status should show Active: active (running). The hello-world container should print This message shows that your installation appears to be working correctly. and exit. If it instead prints a permission error on /var/run/docker.sock, you left off sudo, which the docker group section below fixes.

Check the compose plugin separately, because it is a different package and can be missing while the engine is fine:

docker compose version

A healthy answer looks like Docker Compose version v2.x.x. Getting your services back after a reboot is a separate question from enabling the daemon, and restart policies decide whether Compose services come back on boot.

Why does a bind mount give permission denied?

Rocky Linux and AlmaLinux run SELinux (Security-Enhanced Linux) in enforcing mode by default. Confirm with getenforce, which prints Enforcing.

Docker containers run under the SELinux type container_t, and that type may only read and write files labelled container_file_t. A directory you created on the host carries whatever label its parent path gives it, which is not container_file_t. The container is denied even though the owner, group and mode look correct from the host side. Reproduce it in three commands:

sudo mkdir -p /srv/site
echo hello | sudo tee /srv/site/index.html
sudo docker run --rm -v /srv/site:/usr/share/nginx/html:ro nginx:alpine cat /usr/share/nginx/html/index.html

The container prints:

cat: can't open '/usr/share/nginx/html/index.html': Permission denied

Two commands show you the cause. ls -ldZ /srv/site prints the label, which for a path under /srv is system_u:object_r:var_t:s0, not container_file_t. Then sudo ausearch -m avc -ts recent prints the kernel's audit record, containing avc: denied { read }, an scontext= field naming container_t, and a tcontext= field naming the label you just saw on the directory. The mismatch between those two fields is the whole story.

The fix is a suffix on the volume argument. Docker relabels the path for you:

sudo docker run --rm -v /srv/site:/usr/share/nginx/html:ro,z nginx:alpine cat /usr/share/nginx/html/index.html

:z in lower case relabels the content as shared, so several containers can use the same directory. :Z in upper case relabels it as private and unshared, tied to one container, and a second container reading the same path is then denied. Use :z for anything a sidecar or a backup container also touches. Use :Z for a database directory that one container owns.

Docker's documentation carries a warning worth repeating, because the relabel is recursive. Bind-mounting a system directory such as /home or /usr with :Z "renders your host machine inoperable and you may need to relabel the host machine files by hand". Point these suffixes at directories you created for the container, never at a system path.

In Compose the suffix goes on the same string:

services:
  web:
    image: nginx:alpine
    volumes:
      - /srv/site:/usr/share/nginx/html:ro,z

Two limits are easy to trip over. The --mount flag cannot set an SELinux label at all, so use -v when you need one. Named volumes need no suffix, because Docker labels the directories it creates under /var/lib/docker/volumes itself.

Do not turn SELinux off. Use sudo setenforce 0 only as a one-minute test: if the container then works, the problem is a label and :z is the answer. Put it back with sudo setenforce 1 immediately. On Enterprise Linux, permission denied on a bind mount has two separate causes that look identical from inside the container. One is the SELinux label. The other is ordinary numeric user and group ownership, which is what the PUID and PGID variables exist to solve. ls -lnZ shows you the mode, the numeric owner and the label in one line, so you can tell which one you are fighting.

Why is a published port reachable when firewalld looks closed?

Firewalld is the default firewall on Rocky Linux and AlmaLinux. Check it is running with sudo systemctl is-active firewalld. Now publish a port and look at what firewalld thinks is open:

sudo docker run -d --name web -p 8080:80 nginx:alpine
sudo firewall-cmd --list-ports

firewall-cmd prints an empty line. From another machine, curl -I http://YOUR_SERVER_IP:8080/ returns HTTP/1.1 200 OK. The port is open to the internet and your firewall reports nothing.

The cause is the path the packet takes. Firewalld's zone rules filter traffic addressed to the host itself. A published port is not addressed to the host: Docker installs a destination NAT (network address translation) rule that rewrites the destination to the container's address before the packet reaches the host's input path, so the kernel forwards the packet instead of delivering it locally. Docker then places its bridge interfaces in a firewalld zone called docker whose target is ACCEPT, and adds a forwarding policy called docker-forwarding that allows forwarding from any zone into the docker zone. Your zone rules never see the packet.

The cleanest fix needs no firewall rule. Bind the host side of the publish to loopback and put a reverse proxy in front of it:

sudo docker rm -f web
sudo docker run -d --name web -p 127.0.0.1:8080:80 nginx:alpine
curl -I http://127.0.0.1:8080/

The local curl returns HTTP/1.1 200 OK and the same request from another machine no longer connects. Anything with no host address in the -p argument is published on every interface, so treat a bare -p 8080:80 as a decision to expose that service publicly.

When you do need a service reachable from some addresses and not others, Docker reserves a chain for you. DOCKER-USER is processed before Docker's own accept rules, so a rule you put there survives Docker restarting and rewriting its chains:

sudo iptables -I DOCKER-USER -i enp1s0 ! -s 203.0.113.10 -j DROP
sudo iptables -S DOCKER-USER

Take the interface name from ip route show default rather than assuming eth0, since current EL images use names like enp1s0 or ens3. On Rocky and AlmaLinux the iptables command is a compatibility layer over nftables, and Docker's chains are visible through it. Rules added this way are gone after a reboot unless you save them, so write them into a systemd unit once you are happy with them.

Docker Engine 28.0, released in 2025, closed a neighbouring hole: direct routed access to container ports that were never published is now blocked in the DOCKER chain. That change does not affect published ports, so everything above still applies on current versions. One operational habit is worth forming: after any sudo firewall-cmd --reload, re-test a published port. If it stopped answering, sudo systemctl restart docker reinstalls Docker's rules.

Ubuntu administrators meet the same wall through a different tool, which is why published Docker ports ignore ufw rules. The NAT path is the cause in both cases. Only the firewall in front of it changes.

Add a non-root user to the docker group

Typing sudo before every docker command gets old, and the docker group removes the need:

sudo usermod -aG docker $USER
newgrp docker
docker run --rm hello-world

usermod -aG edits /etc/group, but your current shell already has its group list, so the change does not apply until you get a new one. newgrp docker starts a shell with the group attached so you can test right away. New SSH sessions pick it up on their own.

Be clear about what that group grants. Membership gives write access to /var/run/docker.sock, and anything that can talk to that socket can ask the daemon to start a container that mounts the host filesystem. One command shows what that means:

docker run --rm -v /:/host alpine wc -l /host/etc/shadow

That reads a file only root can read, from an account with no sudo rights. Docker's own post-install documentation says the same thing: the docker group grants privileges equivalent to root. Add an account to it only if you would also give that account sudo. If you are setting up accounts on a new server, decide this alongside the rest of your least-privilege user setup on a VPS rather than after the fact.

Docker also ships a rootless mode that runs the daemon as an unprivileged user. It is a separate install path and it changes how storage drivers and ports below 1024 behave, so plan it as its own project instead of a flag you add later.

Where to go next

You now have the engine, the compose plugin, a service that survives reboot, and the three EL-specific behaviours documented above. The next step is a compose.yaml per service, and the anatomy of a Compose file covers the file format and the commands that drive it. If this is your first container host, running Docker on a VPS covers the sizing, storage and image-hygiene questions this guide leaves out.

FAQ

Does Docker's CentOS repository work on Rocky Linux and AlmaLinux?

Yes. Add https://download.docker.com/linux/centos/docker-ce.repo with dnf config-manager. The baseurl in that file contains $releasever, and Rocky Linux and AlmaLinux expand it to the major version number, so an EL 9 box resolves to the CentOS 9 tree and an EL 10 box to the CentOS 10 tree. Confirm the expansion with sudo dnf repoinfo docker-ce-stable and read the Repo-baseurl line. A Status code: 404 when dnf fetches metadata means the variable expanded to a point release, and editing /etc/yum.repos.d/docker-ce.repo to use the bare major number fixes it.

Can Docker and podman be installed on the same server?

Docker's documentation lists podman and runc as conflicting packages and tells you to remove both before installing Docker Engine. The concrete clash is the podman-docker package, which owns /usr/bin/docker and turns every docker command into a podman command. Run rpm -qf "$(command -v docker)" to see which package owns that path. If the output starts with podman-docker, podman is answering. Keeping both engines is not a layout Docker supports, so on a server that matters, pick one.

Why does my container get permission denied on a bind mount?

SELinux is enforcing by default on Rocky Linux and AlmaLinux. Containers run as the type container_t and may only touch files labelled container_file_t, so a directory you created carries the wrong label and access is denied regardless of its owner and mode. Confirm it with ls -ldZ on the host path and sudo ausearch -m avc -ts recent, which prints avc: denied with the two mismatched contexts. Add :z to the volume argument for content shared between containers, or :Z for content private to one. Never point :Z at /home or /usr, because the relabel is recursive and will break the host.

Do I need to open a port in firewalld to publish a container port?

No, and that is the problem. Docker's NAT rule rewrites the destination address before the packet reaches the host's input path, so firewalld's zone rules never inspect it. Docker also puts its bridges in a firewalld zone called docker with target ACCEPT. A container started with -p 8080:80 is reachable from the internet while sudo firewall-cmd --list-ports prints nothing. Publish to a specific address with -p 127.0.0.1:8080:80 when only the host should reach the service, or insert filtering rules into the DOCKER-USER chain, which Docker processes before its own accept rules.

Is adding my user to the docker group safe?

It grants root. A member of the docker group can write to /var/run/docker.sock, and docker run --rm -v /:/host alpine wc -l /host/etc/shadow then reads a root-only file from an account with no sudo rights. Docker's post-install documentation states the same equivalence. Add only accounts you would already trust with sudo, and keep using sudo docker for shared or service accounts. Rootless mode is the alternative when you need containers under an unprivileged user, and it is a separate install path rather than a setting.