RHEL — the 80/20 course for operating containerised workloads¶
Scope: operate a small fleet of Red Hat Enterprise Linux 9.4+ hosts running containerised workloads (Podman + Quadlet). Workstation usage, RHEL 6/7 legacy, IdM/FreeIPA, deep Satellite work, and migration guides from CentOS 8 are intentionally out of scope — pointers at the end.
Read this top-to-bottom once, then come back to specific sections as reference. The glossary is the dictionary for any term you don't recognise here.
TL;DR — in 30 seconds:
- A RHEL host won't update without an RHSM subscription (
subscription-manager register) —dnfcan't reach official repos without it, andregistry.redhat.ioneeds credentials too (UBI images are the one exception). - SELinux is enforcing by default — most "works on Ubuntu, not on RHEL" surprises are an AVC denial, fixed with
chcon/restorecon/semanageonce you can readjournalctl _AUDIT_TYPE=AVC. - Podman, not Docker, is the container engine (daemonless, rootless-by-default) — this course's scope is running that fleet with Podman + Quadlet, not workstation use or legacy RHEL 6/7.
1. Why RHEL — and what makes it different¶
RHEL is a paid, supported Linux distribution sold (and given away for ≤16 developer machines) by Red Hat. Three things make it the default in regulated industries:
- Long, predictable lifecycle. A major version is supported for ~10 years. RHEL 9 was released May 2022; Full Support runs through May 2027; Maintenance Support through May 2032; ELS available past that. Bug-for-bug compatible across minors.
- Compliance posture out of the box. SELinux is enforcing by default. FIPS mode is one command. SCAP profiles for CIS / STIG / PCI-DSS ship in-box. The OS itself is on every major auditor's "approved" list.
- Support contracts and certification. Behind every running RHEL is a phone number you can call. Hardware vendors certify against RHEL, not Ubuntu. Many regulated procurement processes literally name RHEL.
What RHEL gives you over Ubuntu / Debian / Alpine:
dnf+ RPM, notapt+dpkg. Same conceptual operations, different commands.firewalldas the default host firewall (a zone-based abstraction over nftables), not rawufw/nftables.NetworkManagerfor everything — wired, Wi-Fi, bonds, VLANs. No/etc/network/interfaces.- Podman as the container engine, not Docker. Daemonless, rootless-by-default, OCI-compliant.
- SELinux enforcing, not AppArmor optional. A lot more "permission denied" until you learn the labels.
- No Ubuntu PPAs / Debian backports. Third-party software ships as
.rpmor via official AppStream modules; ad-hoc repos are EPEL or vendor RPMs.
When not to reach for RHEL:
- You want a free, bleeding-edge distribution → Fedora (also Red Hat-made, RHEL's upstream).
- You want a free RHEL-compatible distribution → Rocky Linux or AlmaLinux (downstream rebuilds).
- You don't need long support windows → Ubuntu LTS is shorter (5 years) but simpler licensing.
- You want a minimal container-host OS → Fedora CoreOS, RHEL CoreOS (OpenShift's host), or "Image Mode RHEL" (see §15).
The 80/20 sweet spot RHEL nails: the OS layer for a containerised workload in a regulated or enterprise environment that needs long support, baked-in compliance, and a Red Hat phone number.
2. Mental model — what changes vs. a generic Linux¶
+--------------------------------------+
| RHEL system |
| |
| Kernel + glibc + systemd | <- standard Linux
| / |
| +-- dnf (BaseOS + AppStream | <- RHEL package layer
| | + CodeReady Builder |
| | + EPEL (optional)) |
| | |
| +-- SELinux (enforcing, targeted) | <- mandatory access control
| | |
| +-- firewalld (zones) | <- host firewall
| | |
| +-- NetworkManager | <- network configuration
| | |
| +-- Podman (container engine) | <- containers (Docker absent)
| | + Quadlet (systemd integration) |
| | |
| +-- subscription-manager | <- RHSM client
| + Insights |
| + (Satellite if managed) |
+--------------------------------------+
Three things to internalise:
- Subscription is a hard requirement for updates. Without registering with RHSM (
subscription-manager register),dnfcan't reach official repos. Containers fromregistry.redhat.ioneed credentials too. UBI images are the exception — pull fromregistry.access.redhat.comwithout login. - SELinux is on, in enforcing mode, by default. Most "this works on Ubuntu but not RHEL" surprises are an AVC denial. The fix is almost always one
chcon/restorecon/semanagecommand — but you need to know how to readjournalctl _AUDIT_TYPE=AVC. - Podman is the container engine. Docker is not pre-installed and not in the official repos on RHEL 8+. Symlinking
docker → podmanworks for ~95% of commands; the differences (daemonless, rootless, no docker socket) are the kind that bite once at scale.
3. Setup — subscribing and updating a fresh host¶
A freshly-installed RHEL 9 host needs three things before you can do anything useful: a subscription, the right repositories, the latest patches.
# 1. Confirm version + base state
cat /etc/redhat-release # Red Hat Enterprise Linux release 9.4 (Plow)
uname -r # 5.14.0-427.x.x.el9_4.x86_64
ip a; ip r # is networking up?
nmcli device status # NetworkManager view
getenforce # Enforcing (good)
# 2. Register with RHSM (one-time per host)
sudo subscription-manager register --username YOUR_USER --password 'YOUR_PASS'
# Or with an activation key (preferred for fleet-scale):
sudo subscription-manager register --org="ACCOUNT-ID" --activationkey="prod-rhel9"
# 3. Check subscription state
sudo subscription-manager status
sudo subscription-manager repos --list-enabled
# 4. Apply all updates
sudo dnf upgrade -y
sudo dnf install -y dnf-plugin-versionlock # so you can pin packages later
# 5. Reboot if the kernel was updated
sudo systemctl reboot
Modern RHEL ships with Simple Content Access (SCA) enabled, which means you no longer have to subscription-manager attach after registration — repositories are available as soon as the host is registered.
For a non-production host (your laptop / lab), register with a free Red Hat Developer subscription:
- Sign up at
developers.redhat.com(free). subscription-manager register --username YOUR_DEV_USERNAME.
That subscription entitles up to 16 systems for non-production use.
4. Versions and lifecycle¶
RHEL's release model is the thing you build planning around.
MAJOR RELEASED FULL SUPPORT END MAINTENANCE END ELS AVAILABLE
RHEL 7 2014 2019 2024 through 2028
RHEL 8 2019 2024 2029 through 2032
RHEL 9 2022 2027 2032 through 2035
RHEL 10 2025 2030 2035 ...
Within a major, minors ship every ~6 months and bring new feature backports + module-stream refreshes. You stay on a major for ~10 years, hopping minors as you go.
Three flavours of "extended" support, all paid add-ons:
- EUS (Extended Update Support) — pin a host to a specific minor (e.g. 9.4) and continue to receive security patches for that minor for ~24 months. Useful when you can't take feature bumps on a 6-month cadence (regulated workloads, slow change windows).
- ELS (Extended Life Support) — paid support past the standard 10-year window of a major. For when you have an unsupported app that can't be ported.
- TUS (Telco / Update Services) — niche, telecom-specific extended support tiers.
Default for most teams: track the latest minor, take it within a quarter of release.
subscription-manager release is the command that pins a host to a minor:
sudo subscription-manager release --list # available
sudo subscription-manager release --set=9.4 # pin
sudo subscription-manager release --show # current
sudo subscription-manager release --unset # unpin (track latest)
5. Package management — dnf, RPM, modules¶
The everyday verbs:
# Search + show
dnf search nginx
dnf info nginx
dnf list installed | grep nginx
dnf provides /usr/bin/jq # which package owns a file?
# Install / upgrade / remove
sudo dnf install -y nginx jq htop
sudo dnf upgrade -y # apply all available updates
sudo dnf upgrade -y nginx # one package
sudo dnf remove -y htop
# History (the killer feature)
sudo dnf history # list every transaction
sudo dnf history info 42 # details of transaction 42
sudo dnf history undo 42 # roll back transaction 42
# Locking versions
sudo dnf install -y dnf-plugin-versionlock
sudo dnf versionlock add nginx # pin current version
sudo dnf versionlock list
sudo dnf versionlock delete nginx
rpm is the low-level package CLI you reach for when something's wrong:
rpm -qa | grep nginx # list installed
rpm -qi nginx # show info about installed
rpm -ql nginx # list files installed by nginx
rpm -qf /usr/sbin/nginx # which package owns this file?
rpm -V nginx # verify files vs. install
rpm -qpR ./local.rpm # show requirements of a local rpm
sudo rpm -ivh ./local.rpm # install a local rpm (rare; prefer dnf install ./local.rpm)
Modules ship multiple parallel major versions of a package family. Most common case: Node, Python, PostgreSQL.
dnf module list # what's available
dnf module list nodejs
dnf module info nodejs:20
sudo dnf module enable -y nodejs:20 # pick a stream
sudo dnf install -y nodejs # install from that stream
sudo dnf module reset -y nodejs # back to no stream selected
Repositories:
- BaseOS — kernel, glibc, systemd, essentials. Enabled always.
- AppStream — apps and runtimes (often as modules). Enabled always.
- CodeReady Builder (CRB) —
-develheaders,-debuginfo, build deps. Disabled by default; enable when compiling third-party software. - EPEL (community) —
htop,jq,ncdu,s3cmd, … . Optional and unsupported.
# Enable CRB
sudo subscription-manager repos --enable codeready-builder-for-rhel-9-x86_64-rpms
# Enable EPEL
sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
# Confirm
sudo dnf repolist enabled
For air-gapped or fleet-managed setups, your repositories may come from a Red Hat Satellite or a local mirror. The same dnf verbs apply; the URLs differ.
6. The Red Hat ecosystem — Fedora, CentOS Stream, Rocky, Alma, UBI¶
+-----------+ +------------------+ +--------+ +-----------------+
| Fedora | -> | CentOS Stream | -> | RHEL | -> | UBI containers |
| (fast) | | (next-minor | | | | |
| | | preview) | | | +-----------------+
+-----------+ +------------------+ +--------+
|
+--> Rocky Linux (community downstream rebuild)
+--> AlmaLinux (community downstream rebuild)
What each is for:
- Fedora — Red Hat's upstream. Fast 6-month cadence; features land here first. Useful for dev workstations that want the newest GNOME / kernel; not for servers.
- CentOS Stream — the midstream between Fedora and the next minor RHEL. Tracks what RHEL N.next will be. Useful for testing your apps against next-minor RHEL before it ships.
- RHEL — what you buy / deploy.
- Rocky Linux / AlmaLinux — bug-for-bug compatible downstream rebuilds of RHEL, free. Used by teams that want the RHEL surface without paying Red Hat. Choose at the org level; technically interchangeable for the 80/20 surface.
- UBI (Universal Base Image) — Red Hat's freely-distributable, no-subscription container base images derived from RHEL. Pull from
registry.access.redhat.com/ubi9/ubi:9.4without login. The right base for any container that should behave like RHEL.
A practical fleet pattern: dev on Fedora, build/test on CentOS Stream + UBI, deploy on RHEL.
7. systemd — the boot, services, and logs¶
systemd is the init system + service manager + log collector. Most of RHEL's "how do I start something at boot?" answers go through it.
The vocabulary you'll use daily:
# Status of one service
systemctl status sshd
systemctl is-active sshd
systemctl is-enabled sshd
# Lifecycle
sudo systemctl start sshd
sudo systemctl stop sshd
sudo systemctl restart sshd
sudo systemctl reload sshd # SIGHUP-equivalent if supported
sudo systemctl enable sshd # start on boot
sudo systemctl enable --now sshd # enable + start
sudo systemctl disable sshd
sudo systemctl disable --now sshd
# Listings
systemctl list-units --type=service
systemctl list-units --type=service --state=failed
systemctl list-unit-files --type=service # what could be enabled
systemctl list-dependencies multi-user.target
# After editing unit files
sudo systemctl daemon-reload # re-read units
systemd unit file types you'll see:
.service— supervises a process. The everyday case..target— a group of units.multi-user.targetis the equivalent of SysV runlevel 3..timer— a cron-like trigger that runs another unit.podman-auto-update.timer,dnf-automatic.timer..mount— a managed filesystem mount..socket— socket-activated services (start on first incoming connection).
Logs go through journald — read with journalctl:
journalctl -u sshd # one unit
journalctl -u sshd -f # follow
journalctl -u sshd --since "1 hour ago"
journalctl -u sshd --since "2026-05-20" --until "2026-05-21"
journalctl -p err # priority error and worse
journalctl -p err -p warning # multiple priorities
journalctl _AUDIT_TYPE=AVC # SELinux denials (see §8)
journalctl -k # kernel messages (replaces dmesg)
journalctl --disk-usage
sudo journalctl --vacuum-time=7d # trim logs older than 7d
For a service you write, drop a .service file under /etc/systemd/system/, daemon-reload, enable --now. For containerised services, use Quadlet (see the Quadlet course) — don't write .service files by hand for Podman containers.
8. SELinux — the big section¶
SELinux is the kernel Mandatory Access Control layer that's enforcing by default on every RHEL host. It's the most common source of "this works on Ubuntu but not on RHEL" surprises. Learning to read denials and fix them takes ~30 minutes and pays off forever.
8.1 What it does¶
Every file and every process has an SELinux context: a four-tuple user:role:type[:level]. Access decisions in the kernel are:
"May a process with context X access a file with context Y?"
Answered by the loaded policy (RHEL ships the targeted policy by default). If the policy doesn't allow it, the access is denied — regardless of standard Unix permissions saying it should be fine.
8.2 Mode¶
getenforce # Enforcing | Permissive | Disabled
sudo setenforce 0 # temporarily Permissive
sudo setenforce 1 # back to Enforcing
# Persistent — set in /etc/selinux/config (requires reboot)
SELINUX=enforcing
SELINUXTYPE=targeted
Never run production in Permissive long-term. Use it for short debugging windows only.
8.3 Reading contexts¶
ls -Z /var/www/ # files
ps -eZ | grep nginx # processes
id -Z # your shell
A typical file context: system_u:object_r:httpd_sys_content_t:s0. The type (third field) is what enforcement rules act on; in the targeted policy, the others matter less.
8.4 Reading denials¶
When SELinux blocks an access, it logs an AVC denial to the audit log:
sudo ausearch -m AVC -ts recent # recent AVCs from audit log
journalctl _AUDIT_TYPE=AVC -f # follow live via journald
A typical denial:
type=AVC msg=audit(...): avc: denied { read } for pid=1234 comm="nginx"
name="index.html" dev="vda1" ino=12345
scontext=system_u:system_r:httpd_t:s0
tcontext=unconfined_u:object_r:user_home_t:s0
tclass=file permissive=0
Decode:
scontextis the process (httpd_t= the nginx daemon).tcontextis the file (user_home_t= user's home dir).tclassis what was being attempted (fileread).- nginx can't read files labeled
user_home_t, regardless ofchmod 755.
8.5 Fixes — in order of preference¶
1. Relabel the file. When the file is in the wrong location:
# What context should a file in this path have? Compare:
matchpathcon /srv/www/index.html
# Fix to the right context for this path
sudo restorecon -Rv /srv/www/
# Or set a specific context manually
sudo chcon -t httpd_sys_content_t /srv/www/index.html
restorecon is almost always the right answer. It resets contexts to whatever the policy says they should be for that path. Use it after mv-ing files into a service's expected location (because mv preserves the old context).
2. Add a custom file-context rule. When the file is in a non-default location and you want it to keep the right context after relabels:
sudo semanage fcontext -a -t httpd_sys_content_t "/opt/myapp/web(/.*)?"
sudo restorecon -Rv /opt/myapp/web
3. Toggle an SELinux boolean. When the policy supports your access via a named knob:
sudo semanage boolean -l | grep httpd # show all knobs
sudo setsebool -P httpd_can_network_connect on # one common case
getsebool httpd_can_network_connect
4. Open a non-standard port for a service. When your service listens on a port outside the policy default:
sudo semanage port -a -t http_port_t -p tcp 8888
sudo semanage port -l | grep http_port_t
5. Last resort: a custom policy module. When no rule, boolean, or port works:
# Capture denials, generate a policy module, install it
sudo ausearch -m AVC -ts recent | audit2allow -M myapp
sudo semodule -i myapp.pp
# Verify
sudo semodule -l | grep myapp
Always inspect the generated myapp.te first — audit2allow is happy to grant overly broad permissions. Tighten before installing.
8.6 For containers specifically¶
Container processes run as container_t. To bind-mount a host directory into a container, you must either relabel the host directory for container use (:Z private, :z shared on the Podman -v flag) or disable confinement for that container (--security-opt label=disable, only when truly necessary).
podman run -v /opt/myapp/data:/data:Z myimage # private SELinux label
podman run -v /opt/shared:/shared:z myimage # shared SELinux label
This is the #1 SELinux gotcha when moving from Docker on Ubuntu to Podman on RHEL.
9. firewalld — the host firewall¶
RHEL's host firewall is a daemon (firewalld) + a CLI (firewall-cmd) wrapping nftables. It thinks in zones (named trust levels) and services (named port bundles).
# State
sudo systemctl status firewalld
sudo firewall-cmd --state # running
sudo firewall-cmd --get-default-zone # usually 'public'
sudo firewall-cmd --get-active-zones # zone -> interface mapping
sudo firewall-cmd --list-all # everything for the default zone
# Open services + ports
sudo firewall-cmd --add-service=http --permanent
sudo firewall-cmd --add-service=https --permanent
sudo firewall-cmd --add-port=8080/tcp --permanent
sudo firewall-cmd --reload # apply permanent changes
# Remove
sudo firewall-cmd --remove-service=ftp --permanent
sudo firewall-cmd --remove-port=8080/tcp --permanent
sudo firewall-cmd --reload
# Define a service by hand (rare; the included list covers ~everything)
sudo firewall-cmd --info-service=ssh
Two flags are crucial:
--permanentwrites the change to disk so it survives a reload.--reloadapplies the permanent config to the running daemon.
Without --permanent, your change is in-memory only and is lost on the next reload.
For source-IP-based rules, use rich rules:
sudo firewall-cmd --add-rich-rule='rule family="ipv4" source address="10.0.0.0/8" service name="ssh" accept' --permanent
sudo firewall-cmd --reload
For a host running containers exposed on port 80/443, the firewall config is usually two lines (add-service=http, add-service=https) + a reload. Containers don't have their own firewalld interaction by default — Podman's default Netavark backend manipulates nftables directly via the podman0 / podman1 bridges, which traverse firewalld. (Legacy CNI-backed hosts used cni-podman0; CNI is being phased out.)
The rootless path looks like this:
flowchart LR
subgraph NS["Rootless user namespace"]
C1["Container A"]
C2["Container B"]
end
C1 -->|veth| BR["podman0 / podman1 bridge<br/>(Netavark-managed)"]
C2 -->|veth| BR
BR -->|programs| NFT["nftables rules<br/>(NAT, port publish)"]
NFT -->|traverses| FW["firewalld zones"]
FW --> HOST["Host network"]
10. Networking — NetworkManager and nmcli¶
NetworkManager is RHEL's network configuration daemon. Everything (wired, Wi-Fi, bonds, VLANs, VPN) goes through it. The legacy /etc/sysconfig/network-scripts/ifcfg-eth0 files are read for backwards-compatibility but new config should be NetworkManager-native.
The CLI is nmcli:
# Inventory
nmcli device status # interfaces and their state
nmcli connection show # configured profiles
nmcli connection show eth0 # one profile's details
# Add a static IP profile
sudo nmcli connection add type ethernet con-name static-eth0 ifname eth0 \
ipv4.method manual \
ipv4.addresses 192.168.1.42/24 \
ipv4.gateway 192.168.1.1 \
ipv4.dns "1.1.1.1 8.8.8.8"
sudo nmcli connection up static-eth0
sudo nmcli connection down static-eth0
sudo nmcli connection delete static-eth0
# Modify an existing profile
sudo nmcli connection modify eth0 ipv4.dns "1.1.1.1 8.8.8.8"
sudo nmcli connection modify eth0 ipv6.method disabled
sudo nmcli connection up eth0 # re-apply
# DNS
sudo nmcli connection modify eth0 ipv4.ignore-auto-dns yes # don't use DHCP-supplied DNS
sudo nmcli connection modify eth0 ipv4.dns "1.1.1.1 8.8.8.8"
# Bridge or bond — useful for container hosts
sudo nmcli connection add type bridge con-name br0 ifname br0
sudo nmcli connection add type ethernet con-name br0-port1 ifname eth0 master br0
For interactive editing in a terminal without nmcli muscle memory: nmtui is a curses TUI on top of NetworkManager.
For machine-readable IP info: ip (ip addr, ip route, ip link) — kernel-level, doesn't talk to NetworkManager but reflects the state NM has set.
11. Storage — LVM, XFS, mounts¶
A stock RHEL install puts everything on LVM by default. The layers, bottom up:
[ Physical block devices ] /dev/sda, /dev/sdb, /dev/nvme0n1
|
v
[ LVM Physical Volume ] pvcreate
|
v
[ LVM Volume Group ] vgcreate -- a pool you carve from
|
v
[ LVM Logical Volume ] lvcreate -- the "virtual disk"
|
v
[ Filesystem (XFS) ] mkfs.xfs -- the bytes on disk
|
v
[ Mount point ] /, /var, /home, /var/lib/containers
The everyday CLIs:
# Inspect
lsblk # tree view of block devices
df -hT # mounts and free space
sudo pvs # physical volumes
sudo vgs # volume groups
sudo lvs # logical volumes
sudo vgdisplay vg-name
sudo lvdisplay vg-name/lv-name
# Add a new disk to an existing volume group + extend a logical volume
sudo pvcreate /dev/sdb
sudo vgextend rhel /dev/sdb
sudo lvextend -L +50G /dev/rhel/home
sudo xfs_growfs /home # grow the filesystem to fill the LV
# Mounts
findmnt /var # what is /var?
findmnt --target /var/lib/containers
mount | grep xfs
# Persist a mount in /etc/fstab
sudo blkid /dev/rhel/data # get UUID
echo 'UUID=<uuid> /opt/data xfs defaults 0 0' | sudo tee -a /etc/fstab
sudo mount -a
Two pieces of practical advice:
- Containers eat disk.
/var/lib/containers/on a rootful Podman host grows fast. Either size/vargenerously, or put a separate logical volume at/var/lib/containers/. - XFS resizes up, not down. You can grow an XFS filesystem live; you cannot shrink one. Plan capacity accordingly.
12. Users, sudo, SSH¶
# Create a user with a home dir and bash shell
sudo useradd -m -s /bin/bash thibault
sudo passwd thibault # set a password (or skip; use SSH key only)
sudo usermod -aG wheel thibault # add to wheel group for sudo
# Or with explicit UID/GID for consistency across hosts
sudo useradd -u 5001 -m -s /bin/bash thibault
# Inspect
id thibault
getent passwd thibault
groups thibault
sudo is the standard "run as another user" tool. RHEL's default policy is "members of wheel may run anything as root with their own password":
sudo -l # what I can run as root
sudo -u postgres psql # run as a non-root user
Override sudoers via drop-in files under /etc/sudoers.d/:
sudo visudo -f /etc/sudoers.d/deploy
# Contents:
deploy ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart myapp.service
visudo is mandatory — it validates syntax before saving. A broken /etc/sudoers can lock you out of root.
SSH is the standard remote-access protocol. The OpenSSH server is sshd, configured in /etc/ssh/sshd_config.
Hardening that every RHEL host should have:
# /etc/ssh/sshd_config (or a /etc/ssh/sshd_config.d/00-hardening.conf drop-in)
PermitRootLogin no
PasswordAuthentication no
ChallengeResponseAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
UsePAM yes
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers thibault deploy
sudo sshd -t # validate config
sudo systemctl restart sshd # apply
Authorise a public key for a user:
# On the user's laptop:
ssh-copy-id thibault@host.example.com
# Or by hand on the host:
sudo -u thibault mkdir -p /home/thibault/.ssh
sudo -u thibault chmod 700 /home/thibault/.ssh
echo "ssh-ed25519 AAA... user@laptop" | sudo -u thibault tee -a /home/thibault/.ssh/authorized_keys
sudo chmod 600 /home/thibault/.ssh/authorized_keys
For MFA (TOTP via google-authenticator or hardware tokens via pam_u2f), reach for the PAM stack — out of the 80/20.
13. Containers on RHEL — Podman, UBI¶
RHEL 8+ ships Podman, not Docker. Docker is not in the official repos; the docker.io registry is reachable but you'll be installing the container engine via dnf install podman and using podman as your CLI.
sudo dnf install -y podman
podman info
podman pull registry.access.redhat.com/ubi9/ubi:9.4 # no login needed
podman run --rm -it registry.access.redhat.com/ubi9/ubi:9.4 bash
[ root in container ] # cat /etc/os-release
[ root in container ] # exit
Daily Podman use is covered in depth in the Podman course. The RHEL-specific things to know up front:
- Rootless is the default. Containers run as your unprivileged user via Linux user namespaces. Rootful only when something needs it (privileged ports, kernel modules, certain CNI plugins).
- Bind mounts need SELinux labels. Add
:Z(private) or:z(shared) to any-v /host:/containermount, or you'll get permission-denied at runtime. - No
dockerd, nosystemctl restart docker. Eachpodmaninvocation is self-contained; long-running containers run as conmon child processes, supervised by systemd via Quadlet.
For containerised services that should run at boot: use Quadlet (see the Quadlet course). Don't write .service files by hand for containers.
For container base images that should look and behave like RHEL: use UBI (registry.access.redhat.com/ubi9/ubi:9.4 or its -minimal / -micro / -init variants). UBI is freely distributable; no Red Hat subscription needed to pull or redistribute.
Two more Red Hat container CLIs worth knowing:
skopeo— inspect and copy images between registries (and local storage) without pulling. Used for mirroring, supply-chain checks, air-gapped flows.buildah— build container images. Podman delegatespodman buildto it; direct use lets you build with finer control.
14. Security frameworks — CIS, STIG, OpenSCAP, FIPS¶
The thing RHEL is bought for in regulated industries is its out-of-the-box compliance posture. Three frameworks ship with RHEL and are worth knowing:
14.1 CIS Benchmark / STIG¶
- CIS Benchmark — Center for Internet Security's prescriptive hardening guide. Covers password policy, SELinux, firewalld, audit, services to disable.
- STIG (Security Technical Implementation Guide) — DISA's harder hardening standard for US government / military. Stricter than CIS.
You don't memorise CIS or STIG; you scan against them with OpenSCAP and remediate.
14.2 OpenSCAP¶
OpenSCAP reads SCAP profiles (signed XML bundles encoding a benchmark) and audits the host against them.
sudo dnf install -y scap-security-guide openscap-scanner
# List profiles available for RHEL 9
oscap info /usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
# Run a scan against the CIS profile, level 2 server
sudo oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis \
--results scan.xml \
--report report.html \
/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
# Generate a remediation script (review before running!)
sudo oscap xccdf generate fix \
--profile xccdf_org.ssgproject.content_profile_cis \
--output fix.sh \
scan.xml
report.html is a per-rule pass/fail report you can hand to an auditor. fix.sh is a remediation script — read it before running; some fixes are aggressive (disable services, restrict cipher suites) and may break workloads.
14.3 FIPS mode¶
FIPS (Federal Information Processing Standards) — US government cryptographic standards. RHEL has a FIPS mode that constrains the system's crypto libraries to FIPS-140 validated algorithms.
# Enable
sudo fips-mode-setup --enable
sudo reboot
# Verify (after reboot)
fips-mode-setup --check # FIPS mode is enabled
sysctl crypto.fips_enabled # crypto.fips_enabled = 1
# Disable (only in non-prod)
sudo fips-mode-setup --disable
sudo reboot
Once FIPS is enabled, non-FIPS algorithms (MD5, RC4, etc.) become unavailable. Some software won't run; test before enabling on prod.
15. Image mode RHEL — bootable container images as the OS¶
A newer way of shipping RHEL itself: the OS is a layered, signed bootable container image, atomically updated via bootc. Released as "GA" in RHEL 9.4.
Conceptually:
Traditional RHEL: package manager (dnf) writes individual files to /
Image-mode RHEL: the entire OS userspace is one container image
bootc atomic-updates the OS by switching to a new image
/usr is read-only; /etc and /var are writable
The everyday verbs:
bootc status # which image is booted?
sudo bootc switch quay.io/example/myrhel:1.0 # rebase to a different image
sudo bootc upgrade # pull + stage + reboot to apply
sudo bootc rollback # roll back to the previous image
Use cases:
- Edge fleets. Image-mode RHEL is closer to immutable infrastructure — every host runs a known, signed image; updates are atomic; rollback is one command.
- Air-gapped or strict-compliance environments. Pre-build, sign, and audit the OS image; deploy it.
- GitOps for the OS itself. Build the image in CI; promote tags; hosts pull on
bootc upgrade.
For most server fleets today, traditional package-mode RHEL is still the default. Image mode is the direction of travel; experiment with it on new fleets, migrate when it fits.
16. Cockpit — the web console¶
Cockpit is a browser-based admin UI for a single RHEL host (or, with extensions, a small fleet). Shipped by default with RHEL server installs, optional on minimal installs.
sudo dnf install -y cockpit
sudo systemctl enable --now cockpit.socket # socket-activated; starts on first request
sudo firewall-cmd --permanent --add-service=cockpit
sudo firewall-cmd --reload
Visit https://<host>:9090 and log in with a sudoer account.
What you get out of the box: metrics dashboard, journal viewer, service list, network configuration, storage (LVM/XFS), users, software updates, terminal, podman containers. Useful for one-host admin and for handing a UI to less-CLI-fluent operators; not a replacement for ssh + scripts for fleet management.
Insights (the hosted advisor service) and Satellite (on-prem content management) are the next steps up in management surface — out of the 80/20.
17. Worked example — a hardened RHEL 9 host running a Quadlet container¶
End-to-end. Take a freshly-installed RHEL 9.4 host, subscribe it, baseline-upgrade it, lock it down, and run one Quadlet container behind firewalld.
# 0. Verify base state
cat /etc/redhat-release
ip a; ip r
getenforce # Enforcing
sudo systemctl status firewalld
# 1. Subscribe + upgrade
sudo subscription-manager register --username YOUR_USER --password 'YOUR_PASS'
sudo subscription-manager status
sudo dnf upgrade -y
sudo systemctl reboot
# 2. (Optional) Add EPEL for jq / htop / etc.
sudo dnf install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm
sudo dnf install -y htop jq
# 3. Create an admin user with sudo + SSH key
sudo useradd -m -s /bin/bash thibault
sudo usermod -aG wheel thibault
sudo -u thibault mkdir -p /home/thibault/.ssh
sudo -u thibault chmod 700 /home/thibault/.ssh
echo "ssh-ed25519 AAA... thibault@laptop" | sudo -u thibault tee /home/thibault/.ssh/authorized_keys
sudo chmod 600 /home/thibault/.ssh/authorized_keys
# 4. Lock down sshd
sudo tee /etc/ssh/sshd_config.d/00-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
AllowUsers thibault
ClientAliveInterval 300
ClientAliveCountMax 2
EOF
sudo sshd -t # validate
sudo systemctl restart sshd
# 5. Lock down firewalld
sudo firewall-cmd --list-all # see what's allowed
sudo firewall-cmd --remove-service=cockpit --permanent # close cockpit until we want it
sudo firewall-cmd --add-service=https --permanent # open 443
sudo firewall-cmd --reload
sudo firewall-cmd --list-all
# 6. Install Podman + enable auto-update
sudo dnf install -y podman
sudo systemctl enable --now podman-auto-update.timer
# 7. Pull a UBI base image (no login needed)
sudo podman pull registry.access.redhat.com/ubi9/ubi:9.4
# 8. Drop a Quadlet container running Caddy on :443 (for the example, with a self-signed cert)
sudo tee /etc/containers/systemd/caddy.container >/dev/null <<'EOF'
[Unit]
Description=Caddy reverse proxy
After=network-online.target
Wants=network-online.target
[Container]
Image=docker.io/library/caddy:2-alpine
ContainerName=caddy
PublishPort=443:443
Volume=/etc/caddy/Caddyfile:/etc/caddy/Caddyfile:ro,Z
Volume=caddydata.volume:/data:Z
AutoUpdate=registry
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo tee /etc/containers/systemd/caddydata.volume >/dev/null <<'EOF'
[Unit]
Description=Caddy data volume
[Volume]
VolumeName=caddydata
[Install]
WantedBy=multi-user.target
EOF
# Minimal Caddyfile that serves a static page
sudo mkdir -p /etc/caddy
sudo tee /etc/caddy/Caddyfile >/dev/null <<'EOF'
:443 {
tls internal
respond "Hello from RHEL 9"
}
EOF
sudo restorecon -Rv /etc/caddy # SELinux label fix
# 9. Activate
sudo systemctl daemon-reload
sudo systemctl enable --now caddy.service
journalctl -u caddy.service -n 30 --no-pager
curl -k https://localhost # Hello from RHEL 9
# 10. Verify the SELinux + firewalld + auto-update setup
podman ps
sudo firewall-cmd --list-all
sudo systemctl list-timers podman-auto-update.timer
ls -lZ /etc/caddy/Caddyfile # context: container_file_t (or similar)
# 11. Run an OpenSCAP scan
sudo dnf install -y scap-security-guide openscap-scanner
sudo oscap xccdf eval \
--profile xccdf_org.ssgproject.content_profile_cis \
--report /tmp/cis-report.html \
/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml || true # exit code may be nonzero on findings
# Inspect /tmp/cis-report.html, decide what to remediate.
# 12. (Optional) Enable FIPS — non-prod first; some software may break
# sudo fips-mode-setup --enable
# sudo reboot
# fips-mode-setup --check
When that whole pipeline behaves end-to-end — subscribed host, hardened SSH, locked firewalld, Quadlet-managed Caddy on :443 with SELinux labels right and restorecon clean, OpenSCAP report in hand — you have the 80%.
18. What's intentionally out of scope here¶
| Topic | Pointer when you need it |
|---|---|
| Workstation / desktop usage | RHEL is a server distribution. For a desktop, use Fedora Workstation (same family, faster cadence). |
| RHEL 7 / yum migration | Lifecycle ended 2024. If you're still on 7, migrate to 8 or 9; this course assumes 9. |
| Red Hat Satellite | The on-prem fleet content + provisioning + patching tool. Worth a separate study if you manage > ~100 hosts. |
| Red Hat Ansible Automation Platform | The commercial wrapper around Ansible Tower / AWX. Use Ansible itself (see the Ansible course) for the OSS surface. |
| IdM / FreeIPA | Red Hat's LDAP + Kerberos + DNS + CA all-in-one identity stack. Niche; AD-joined hosts use realmd instead. |
yum quirks |
yum is symlinked to dnf on RHEL 8+. Behaves identically for the 80/20. |
| realmd / AD join | Joining a RHEL host to Active Directory. man realmd; out of the everyday loop. |
| NFS / Samba server setup | Both well-supported; reach for the official RHEL docs when needed. |
| kdump / crash analysis | RHEL ships kdump; configure when a kernel panic actually happens, not preemptively. |
| Live kernel patching (kpatch) | Available with Premium subscriptions; reach for it on hosts you can't reboot. |
| systemd-resolved | Not default on RHEL (NetworkManager + classic resolv.conf). Mentioned so the term isn't a mystery. |
19. Reference card — daily commands¶
# Subscription
sudo subscription-manager register --username U --password 'P'
sudo subscription-manager status
sudo subscription-manager repos --list-enabled
sudo subscription-manager release --set=9.4
sudo subscription-manager release --unset
sudo subscription-manager unregister
# Packages
sudo dnf upgrade -y
sudo dnf install -y NAME
sudo dnf remove -y NAME
dnf search TERM
dnf info NAME
dnf list installed | grep NAME
dnf provides /usr/bin/jq
sudo dnf history
sudo dnf history undo N
sudo dnf module list
sudo dnf module enable -y nodejs:20
rpm -qa | grep NAME
rpm -ql NAME
rpm -qf /path
rpm -V NAME
# systemd
systemctl status NAME
sudo systemctl start|stop|restart|reload NAME
sudo systemctl enable --now NAME
sudo systemctl disable --now NAME
sudo systemctl daemon-reload
systemctl list-units --type=service --state=failed
journalctl -u NAME -f
journalctl --since "1 hour ago"
journalctl -p err
journalctl _AUDIT_TYPE=AVC
# SELinux
getenforce
sudo setenforce 0|1
ls -Z PATH
sudo restorecon -Rv PATH
sudo chcon -t TYPE PATH
sudo semanage fcontext -a -t TYPE 'PATH(/.*)?'
sudo semanage port -a -t TYPE -p tcp PORT
sudo setsebool -P NAME on|off
sudo ausearch -m AVC -ts recent
sudo ausearch -m AVC -ts recent | audit2allow -M MODULE
sudo semodule -i MODULE.pp
# firewalld
sudo firewall-cmd --list-all
sudo firewall-cmd --add-service=NAME --permanent
sudo firewall-cmd --add-port=PORT/PROTO --permanent
sudo firewall-cmd --remove-service=NAME --permanent
sudo firewall-cmd --reload
# NetworkManager
nmcli device status
nmcli connection show
sudo nmcli connection add type ethernet con-name NAME ifname IFACE \
ipv4.method manual ipv4.addresses CIDR ipv4.gateway GW ipv4.dns "DNS DNS"
sudo nmcli connection modify NAME KEY VAL
sudo nmcli connection up NAME
sudo nmcli connection down NAME
# Storage
lsblk
df -hT
sudo pvs ; sudo vgs ; sudo lvs
sudo vgextend VG /dev/sdb
sudo lvextend -L +SIZE VG/LV
sudo xfs_growfs MOUNTPOINT
sudo blkid
sudo mount -a
# Users + SSH
sudo useradd -m -s /bin/bash NAME
sudo usermod -aG wheel NAME
sudo passwd NAME
sudo visudo -f /etc/sudoers.d/NAME
ssh-copy-id user@host
sudo sshd -t
sudo systemctl restart sshd
# Containers
sudo dnf install -y podman
podman info
podman pull IMAGE
podman run -d --name N IMAGE
podman ps
podman logs N
podman stop N ; podman rm N
# Security
sudo dnf install -y scap-security-guide openscap-scanner
sudo oscap xccdf eval --profile PROFILE_ID --report report.html \
/usr/share/xml/scap/ssg/content/ssg-rhel9-ds.xml
sudo fips-mode-setup --enable && sudo reboot
fips-mode-setup --check
# Cockpit
sudo systemctl enable --now cockpit.socket
sudo firewall-cmd --add-service=cockpit --permanent && sudo firewall-cmd --reload
# https://HOST:9090
# Image mode (if applicable)
bootc status
sudo bootc upgrade
sudo bootc rollback
sudo bootc switch IMAGE
That's the 80%. Subscribe a fresh RHEL 9 host, dnf upgrade, harden SSH, lock firewalld, and run one Quadlet container — the rest layers on top.
20. How this connects¶
- This course's own submodules — LVM & storage, Networking & firewall, systemd & journald, and SELinux & users go deeper on §11's volume stack, §9–§10's zones and NICs, §7's units and logs, and §8's policy and category model respectively. Each one explicitly frames itself as "what to reach for" once this page's 80% case isn't enough — read them next if a section here felt compressed.
- Podman's course page (module 03, Containers, §9
:Z/:zrelabel) — reciprocal: that page already points back here for the Multi-Category Security mechanism underneath the relabel: every container gets the samecontainer_ttype, but:Zassigns a fresh, isolated category pair while:zjoins a shared one — same type, different category, denied by default. §8 here is that mechanism in full. - Quadlet (module 10, Quadlet,
course.md§2 and01-container-unit.md§5) — reciprocal: both pages already point back to §7's "edit via drop-in, never the generated file" discipline as the same rule one level up — a package update can't silently overwrite a drop-in the waydaemon-reloadoverwrites a hand-edit to a Quadlet-generated unit. - Helmfile's release DAG (module 06, Helmfile, §5) — reciprocal: that
page already contrasts its own single
needs:line against §7'sWants=/Requires=(does it need the unit at all) vsAfter=/Before=(in what order) split — two separate questions systemd answers separately, that Helmfile answers with one directive.