AlmaLinux is a firmly established binary-compatible enterprise distribution in the Red Hat ecosystem. Red Hat's default stack is its own container toolchain around Podman, Buildah and Skopeo, but existing development pipelines, third-party tools and multi-container environments still often require the original Docker Engine (Docker CE) plus modern Docker Compose V2.
Putting Docker Engine onto enterprise Linux is a different job than on Debian or Ubuntu. SELinux needs a precise understanding of volume labels (:z and :Z), packet filtering through firewalld needs clean port openings, and preinstalled Podman packages have to come off the system first so they do not fight Docker for the socket interface.
Docker CE and the Docker Compose CLI plugin go onto AlmaLinux 9 and current AlmaLinux 10 in a production-ready layout. The daemon is hardened through daemon.json, and network plus storage isolation is configured so the engine actually runs without the usual RHEL-family surprises.
Architecture and enterprise specifics
On AlmaLinux a container engine that historically grew up around Debian and Ubuntu meets an uncompromising enterprise architecture. Three core pieces have to be understood before runtime problems show up later:
Package conflicts with Red Hat tools:
AlmaLinux ships Podman and the crun package from its default repositories. Both tools expose similar command structures and socket files. Leave those packages in place and the DNF transaction for the official Docker RPM packages fails on file collisions.
SELinux as the default watchdog:
SELinux on AlmaLinux runs in Enforcing mode by default. Containers run in a restrictive sandbox (container_t). When a container reaches host directories through a bind mount, the Linux kernel immediately blocks access with EACCES (Permission Denied) unless the filesystem target carries the SELinux type container_file_t.
Network interaction with firewalld:
Docker manages port forwarding and masquerading through direct iptables and nftables rules. A firewall restart or reload (firewall-cmd --reload) flushes those dynamic rules out of the kernel, and containers lose network access until the Docker service is restarted.
┌─────────────────────────────────────────────────────────────┐
│ AlmaLinux Docker and SELinux enterprise architecture │
├─────────────────────────────────────────────────────────────┤
│ │
│ Client / CLI (docker and docker compose) │
│ │ │
│ ▼ Unix socket (/var/run/docker.sock) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ dockerd (container engine and REST API daemon) │ │
│ └──────────────────────────┬──────────────────────────┘ │
│ │ │
│ ▼ containerd / runc │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Container instances (namespaces, cgroups v2) │ │
│ └───┬─────────────────────────────────────────────┬───┘ │
│ │ │ │
│ ▼ firewalld (port/NAT) ▼ │
│ ┌─────────────┐ ┌─────────┐ │
│ │ nftables │ │ SELinux │ │
│ │ filtering │ │ (:z/:Z) │ │
│ └─────────────┘ └────┬────┘ │
│ │ │
│ ▼ │
│ Host storage │
│ │
└─────────────────────────────────────────────────────────────┘
The Docker Engine on AlmaLinux uses cgroups v2 and the overlay2 storage driver by default. Both mechanisms are native in the enterprise kernel.
Prerequisites and system preparation
You need a server running AlmaLinux 9 or AlmaLinux 10, SSH access, and a user account with administrative rights through sudo.
Check kernel and distribution release
First verify the installed AlmaLinux version and the active Linux kernel:
cat /etc/os-release
uname -r
AlmaLinux 9 is based on Linux kernel 5.14, while AlmaLinux 10 runs modern 6.x kernels. Both variants meet every requirement for namespaces, control groups v2 and OverlayFS.
Update the system
Bring every installed system package up to date through the package manager:
sudo dnf upgrade -y
If the update installed a new kernel, reboot the server once:
sudo reboot
Clean up preinstalled container artefacts
Many AlmaLinux installation profiles already ship tools from the Podman ecosystem. Those packages can claim the same man pages, socket names and command aliases, so you remove competing packages completely:
sudo dnf remove -y podman buildah skopeo docker docker-client docker-client-latest docker-common docker-latest docker-latest-logrotate docker-logrotate docker-engine
💡 Clean package base: Removing Podman and old Docker packages keeps DNF from aborting later dependency resolution with cryptic error messages. Existing configuration files under
/etc/stay untouched.
Configure the official Docker repository
Docker provides its own RPM repositories for enterprise Linux systems. On AlmaLinux and Rocky Linux you use the official CentOS repository.
⚠️ CentOS vs. RHEL repository: On AlmaLinux always use the repository URL
download.docker.com/linux/centos/docker-ce.repo. The sibling repositorylinux/rhel/is historically meant for Red Hat subscription systems and regularly produces HTTP 404 package errors on AlmaLinux minor-point releases.
Add the repository on AlmaLinux 9 (DNF4)
AlmaLinux 9 uses DNF4. Install the dnf-plugins-core package first so the configuration manager is available:
sudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo
Add the repository on AlmaLinux 10 (DNF5)
AlmaLinux 10 uses the rewritten DNF5 package manager. Syntax for external repositories is simpler here and no longer needs a separate plugin package:
sudo dnf config-manager addrepo --from-repofile=https://download.docker.com/linux/centos/docker-ce.repo
Import the GPG key and refresh the cache
Import Docker's official signing key so DNF can cryptographically verify the authenticity of every downloaded RPM package:
sudo rpm --import https://download.docker.com/linux/centos/gpg
sudo dnf makecache
Then check that the repository is listed as active:
dnf repolist | grep docker
Output: docker-ce-stable Docker CE Stable - ...
Install Docker Engine and Docker Compose V2
With the package source in place, install the full container toolchain. Outdated wrapper scripts and manual binary downloads from GitHub stay out of the picture; native RPM packages are the path.
Install the core packages
Install the Docker Engine, the CLI, the containerd runtime, and the official CLI plugins for Buildx and Compose:
sudo dnf install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
The role of each package:
containerd.io: The standardised container runtime that steers container lifecycle at kernel level.docker-ce: The actual Docker daemon (dockerd), which exposes the REST API and manages networks, volumes and images.docker-ce-cli: Thedockercommand-line tool that controls the daemon.docker-buildx-plugin: Extends the CLI with modern BuildKit features for parallel and multi-architecture builds.docker-compose-plugin: Integrates Docker Compose V2 as a native subcommand (docker compose) directly into the Docker CLI.
Verify package versions
After the transaction completes, check the installed software versions:
rpm -qa | grep -E 'docker|containerd'
Expected output (example):
containerd.io-1.7.25-3.1.el9.x86_64
docker-ce-27.3.1-1.el9.x86_64
docker-ce-cli-27.3.1-1.el9.x86_64
docker-compose-plugin-2.31.0-1.el9.x86_64
docker-buildx-plugin-0.19.1-1.el9.x86_64
Service control and autostart through systemd
After package installation the Docker service on enterprise Linux is neither started nor enabled for boot by default.
Start and enable the Docker service
Start the daemon and use the --now switch so the systemd unit comes up automatically on every server reboot:
sudo systemctl enable --now docker
Check the daemon state:
sudo systemctl status docker
The output must show Active: active (running). Check the core parameters of the running engine with docker info:
sudo docker info | grep -E 'Server Version|Storage Driver|Cgroup Version'
Output:
Server Version: 27.3.1
Storage Driver: overlay2
Cgroup Version: 2
Set up non-root access
By default the Unix socket /var/run/docker.sock belongs to user root and group docker. To run Docker commands day to day without prefixing sudo every time, add your system user to the docker group:
sudo usermod -aG docker $USER
For the new group membership to take effect, either run newgrp docker or log out of SSH once and log back in.
⚠️ Security risk of the docker group: Membership in the
dockergroup is effective root on the host. A simple bind mount of/into a privileged container lets you manipulate the host filesystem at will. Grant this permission only to trusted administrators, never to service accounts.
Verify access without extra administrative rights:
docker run --rm hello-world
If Hello from Docker! appears, your unprivileged shell is talking to the daemon correctly.
Enterprise hardening: SELinux and volume mounts
The interaction between Docker and SELinux on AlmaLinux is one of the most common failure modes for administrators who come from Ubuntu or Debian.
The problem: EACCES on bind mounts
When you bind a directory from the host (for example a web root or configuration files) into a container, SELinux type checks kick in. A process inside the container has context container_t. The directory on the host typically has a context such as unconfined_u:object_r:user_home_t:s0 or var_t.
Because container_t must not access ordinary user files, the Linux kernel blocks the access immediately:
# This mount fails inside the container with SELinux enforcing:
docker run -d -p 8080:80 -v /srv/web/html:/usr/share/nginx/html:ro nginx:alpine
# Result in the container log: "open() /usr/share/nginx/html/index.html failed (13: Permission denied)"
The solution: the volume flags :z and :Z
Docker ships built-in switches that automatically adjust the SELinux context of the mounted host directory:
:z(shared): Assigns the directory the contextcontainer_file_t. Several independent containers may read and write this directory at the same time.:Z(private / exclusive): Assigns the directory a uniquely labelled MCS label (container_file_t:s0:c123,c456). Only that one specific container has access. No other container on the system can reach this data.
┌─────────────────────────────────────────────────────────────┐
│ SELinux volume isolation and mount flags (:z / :Z) │
├─────────────────────────────────────────────────────────────┤
│ │
│ Host filesystem (default context: unconfined_u:..._t) │
│ │ │
│ ├────────────────────────┬───────────────────┐ │
│ ▼ No flag ▼ With flag :z ▼ :Z │
│ ┌────────────────────┐ ┌────────────────────────────┐ │
│ │ Access blocked! │ │ container_file_t (shared) │ │
│ │ (HTTP 403/EACCES) │ │ Multiple containers access │ │
│ └────────────────────┘ └────────────────────────────┘ │
│ │ │
│ ▼ │
│ Safe container access │
│ │
└─────────────────────────────────────────────────────────────┘
🔧 Practical example:
Create a directory for static HTML files and bind it with the :z flag:
mkdir -p /srv/web/html
echo "<h1>AlmaLinux with Docker and SELinux</h1>" > /srv/web/html/index.html
# Start the Nginx container with the :z flag:
docker run -d --name webtest -p 8080:80 -v /srv/web/html:/usr/share/nginx/html:ro,z nginx:alpine
Check the SELinux context of the mounted folder on the host:
ls -Zd /srv/web/html
Output: system_u:object_r:container_file_t:s0 /srv/web/html
With container_file_t in place, Nginx inside the container reads the file without access errors. Never disable SELinux (setenforce 0) just to paper over permission problems — use the :z or :Z flags consistently instead.
firewalld integration and network hardening
On AlmaLinux, firewalld owns packet filtering for the host. The interaction between Docker and firewalld has a technical quirk administrators need to know:
iptables bypass and port openings
With networking enabled, Docker rewrites the kernel iptables and nftables tables on its own (nat table in the PREROUTING chain and filter table in the DOCKER chain). Publish a port with -p 8080:80 and that rule hits in the Linux kernel before the regular ingress rules of the firewalld zones.
Explicit firewalld openings on enterprise systems are still mandatory:
sudo firewall-cmd --permanent --add-port=8080/tcp
sudo firewall-cmd --reload
The firewall reload conflict
⚠️ Watch
firewall-cmd --reload: When you reloadfirewalldat runtime, the firewall daemon flushes every kernel filter table. The routing and masquerading rules Docker injected dynamically are wiped completely. Running containers can then neither reach the internet nor receive inbound packets.
If a firewall change cuts networking, restart the Docker daemon so it rebuilds its filter chains in the kernel:
sudo systemctl restart docker
Permanently secure the bridge interface
So that firewalld does not drop forwarded traffic over the virtual Docker bridge docker0, assign the interface to the trusted zone:
sudo firewall-cmd --permanent --zone=trusted --add-interface=docker0
sudo firewall-cmd --reload
sudo systemctl restart docker
Production configuration: /etc/docker/daemon.json
In enterprise operation the Docker Engine defaults should not be taken as-is. Unbounded log files and container deaths during daemon upgrades are typical operational risks.
Create the central configuration file /etc/docker/daemon.json:
sudo nano /etc/docker/daemon.json
Put the following hardened, validated configuration in place:
{
"log-driver": "json-file",
"log-opts": {
"max-size": "20m",
"max-file": "5"
},
"live-restore": true,
"userland-proxy": false
}
What the settings do:
log-opts (max-size & max-file): Caps each container's JSON log files at 20 MB and rotates across 5 files. That reliably stops noisy or broken containers from filling the root partition.live-restore: true: Lets the Docker Engine keep running containers alive when thedockerdprocess is restarted or updated throughdnf upgrade. That cuts downtime during maintenance windows drastically.userland-proxy: false: Turns off the extra helper processdocker-proxy. Traffic is then routed directly through kernel NAT (iptables/nftables), which is faster and cheaper on resources.
Important hardening note on no-new-privileges:
A common recommendation is to enable no-new-privileges so privilege escalation through setuid or setgid binaries inside containers is blocked. That option does not exist as a global key in daemon.json in the Docker ecosystem (the daemon would refuse to start with an error). Declare it per container with --security-opt=no-new-privileges:true or in the Compose file instead.
Load the new configuration by restarting the daemon:
sudo systemctl restart docker
Verify the active settings:
docker info | grep -E 'Logging Driver|Live Restore'
Practical use with Docker Compose V2
Docker Compose V2 is fully integrated into the Docker command set as a CLI plugin. The modern invocation is docker compose with a space (not the obsolete Python tool docker-compose with a hyphen).
Create a multi-container project
Create a project directory for a typical web stack of an Nginx frontend and a Redis cache:
mkdir -p ~/webstack/html
cd ~/webstack
echo "<h1>Production stack on AlmaLinux</h1>" > html/index.html
Create the configuration file docker-compose.yml:
nano docker-compose.yml
Paste the following stack. Note the :ro,z flag on the host volume for SELinux:
name: enterprise-stack
services:
web:
image: nginx:alpine
container_name: web_frontend
ports:
- "8080:80"
volumes:
- ./html:/usr/share/nginx/html:ro,z
security_opt:
- no-new-privileges:true
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/ || exit 1"]
interval: 15s
timeout: 5s
retries: 3
cache:
image: redis:alpine
container_name: redis_cache
command: redis-server --appendonly yes
volumes:
- redis_data:/data
security_opt:
- no-new-privileges:true
restart: unless-stopped
volumes:
redis_data:
Start and monitor the stack
Start the entire stack in the background:
docker compose up -d
Docker Compose pulls the images, creates the isolated network enterprise-stack_default, sets up the named volume redis_data and starts the containers.
Check the service state:
docker compose ps
Expected output:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
redis_cache redis:alpine "docker-entrypoint.s…" cache 15 seconds ago Up 14 seconds 6379/tcp
web_frontend nginx:alpine "/docker-entrypoint.…" web 15 seconds ago Up 14 seconds (healthy) 0.0.0.0:8080->80/tcp
Check access to the web server from the command line:
curl http://127.0.0.1:8080
Output: <h1>Production stack on AlmaLinux</h1>
Stop the stack cleanly when you no longer need it:
docker compose down
Maintenance, updates and troubleshooting
Stable production operation needs standardised maintenance procedures and fast intervention when something breaks.
Apply Docker Engine updates
Because the official package source is configured, updates for the Docker Engine and the Compose plugin arrive through the system package manager:
# On AlmaLinux 9:
sudo dnf check-update | grep docker
sudo dnf upgrade -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
# On AlmaLinux 10:
sudo dnf upgrade -y 'docker-*' containerd.io
With "live-restore": true in daemon.json, existing containers keep running without interruption while the daemon updates.
Analyse SELinux denials
If a container throws unexpected permission errors, check the audit log for SELinux AVCs:
sudo ausearch -m avc -ts recent
A denied { read } for a container there means the volume mount is missing the matching :z flag, or the directory needs an explicit context assignment. For a fast temporary adjustment, chcon is enough:
sudo chcon -Rt container_file_t /path/to/directory
To persist the SELinux type so a system relabel via restorecon does not overwrite the permission, use semanage:
sudo semanage fcontext -a -t container_file_t "/path/to/directory(/.*)?"
sudo restorecon -Rv /path/to/directory
Clean up unused resources
Old container images, orphaned build caches and unused networks eat flash storage over time. Clean those artefacts in a controlled way:
docker system prune -a --volumes -f
Command Reference (Cheatsheet)
The core commands for managing Docker and Compose on AlmaLinux at a glance:
| Command | Purpose | Context |
|---|---|---|
sudo dnf install -y docker-ce docker-compose-plugin |
Install Docker Engine and Compose V2 | Package manager |
sudo systemctl enable --now docker |
Start the Docker daemon and enable autostart | systemd |
sudo usermod -aG docker $USER |
Add the user to the docker group | User management |
docker info |
Show full system and storage-driver details | Diagnostics |
docker run --rm -v ./data:/app:z image |
Bind a host directory with the SELinux shared label | Container start |
sudo firewall-cmd --permanent --add-port=8080/tcp |
Open a container port in the host firewall | firewalld |
sudo firewall-cmd --reload && sudo systemctl restart docker |
Reload the firewall and regenerate Docker networking | Network maintenance |
docker compose up -d |
Start a multi-container stack in the background | Compose |
docker compose ps |
Check status of all services and healthchecks | Compose |
docker compose logs -f --tail=50 |
Follow live logs of the active stack | Troubleshooting |
docker compose down |
Stop the stack cleanly and remove networks | Compose |
docker system prune -f |
Clean unused images and build caches | Disk maintenance |
Further Resources
Official documentation and material for deeper study:
| Resource | Description | Type |
|---|---|---|
| Docker Engine documentation | Official handbook for installation, configuration and storage | Documentation |
| Docker Compose specification | Reference for Compose YAML files and service attributes | Reference |
| AlmaLinux Wiki | Official administration guides and release documentation | Documentation |
| Red Hat SELinux Guide | In-depth documentation of mandatory access control on RHEL | Reference |
| firewalld documentation | Zone, interface and policy management for Linux firewalls | Documentation |
Conclusion
This deployment gives you a stable, low-maintenance, fully enterprise-capable Docker environment on AlmaLinux. The official CentOS repository delivers reliable updates, and the native Compose CLI plugin enables modern multi-container deployments without external dependencies.
Accounting for the enterprise specifics — especially setting the SELinux mount flags :z consistently and opening ports cleanly in firewalld — avoids the typical traps that unprepared setups hit on RHEL-based systems.
💡 Practical tip for production: Configure
/etc/docker/daemon.jsonbefore the first production container start. Parameters such aslog-optsandlive-restoreprotect the system permanently against filesystems filling up and allow uninterrupted daemon updates during regular maintenance windows.