RHEL Basics #7: Basic Security — firewalld, SSH Hardening

11 min read

So far we’ve installed packages, started services, created users, and added disks. The last piece is restricting what gets in from the outside — the firewall (firewalld) and SSH hardening. The final step needed to run a single RHEL machine safely.

Where this post sits in the RHEL Basics series:

firewalld — RHEL’s firewall abstraction #

The Linux kernel’s packet filtering is handled by netfilter (it used to be iptables, now it’s nftables). The abstraction tool on top is firewalld on RHEL — the equivalent of Ubuntu’s ufw.

firewalld status
$ sudo systemctl status firewalld
● firewalld.service - firewalld - dynamic firewall daemon
     Active: active (running)
       ...

$ sudo firewall-cmd --state
running

On RHEL 9 it’s on by default with the public zone activated.

Zones are the core idea #

Where iptables is a flat chain of rules, firewalld groups interfaces into zones and applies a policy per zone. Even on the same machine, packets can hit different policies depending on which NIC they came in on.

zone shape
   eth0 (external)  ──→  public zone   →  strict policy
   eth1 (internal)  ──→  internal zone →  loose policy
   wg0  (VPN)       ──→  trusted zone  →  almost everything allowed

Predefined zones:

zoneDescription
dropStrictest. Drops incoming traffic with no response
blockLike drop but sends a reject response
publicDefault — only some services allowed
externalNAT (masquerading) on
dmzFor DMZ servers
work / home / internalDesktop trust levels
trustedAllow all traffic

View current zones and active rules #

check zones
$ sudo firewall-cmd --get-default-zone
public

$ sudo firewall-cmd --get-active-zones
public
  interfaces: enp0s1

$ sudo firewall-cmd --list-all
public (active)
  target: default
  icmp-block-inversion: no
  interfaces: enp0s1
  sources:
  services: cockpit dhcpv6-client ssh
  ports:
  protocols:
  forward: yes
  masquerade: no
  forward-ports:
  source-ports:
  rich rules:

The services: cockpit dhcpv6-client ssh line shows what’s currently allowed. Because SSH is open by default, we could SSH in back in #2.

firewall-cmd — permanent vs runtime #

Start with the most commonly confused bit. Changes in firewalld have two dimensions:

OptionWhere it applies
(none)Runtime only — gone after reboot or reload
--permanentPersistent config only — applies after a reload

The standard pattern is almost always:

pattern
$ sudo firewall-cmd --permanent --add-service=http      # add to persistent config
$ sudo firewall-cmd --reload                            # reload persistent config into runtime

Or in a single shot:

apply both at once (common)
$ sudo firewall-cmd --add-service=http                  # immediate runtime change
$ sudo firewall-cmd --runtime-to-permanent              # copy runtime state into persistent

--reload does not drop active connections — you can reload while SSH’d in and not lose your session. Still, get into the habit of running --list-all once afterward to confirm the change went in as intended.

Adding and removing services / ports #

Predefined services #

firewalld has names for common services: ssh (22), http (80), https (443), cockpit (9090), and so on.

add / remove services
$ sudo firewall-cmd --permanent --add-service=http
$ sudo firewall-cmd --permanent --add-service=https
$ sudo firewall-cmd --permanent --remove-service=cockpit
$ sudo firewall-cmd --reload

$ sudo firewall-cmd --list-services
ssh dhcpv6-client http https

Listing the predefined services:

list
$ sudo firewall-cmd --get-services | tr ' ' '\n' | head -20
RH-Satellite-6
RH-Satellite-6-capsule
amanda-client
amanda-k5-client
amqp
...

Open ports directly #

For ports without a predefined service:

add / remove ports
$ sudo firewall-cmd --permanent --add-port=8000/tcp
$ sudo firewall-cmd --permanent --add-port=5000-5010/tcp     # range
$ sudo firewall-cmd --permanent --remove-port=8000/tcp
$ sudo firewall-cmd --reload

$ sudo firewall-cmd --list-ports
8000/tcp 5000-5010/tcp

Per-zone #

You can drop --zone= anywhere in the command:

MySQL only on internal zone
$ sudo firewall-cmd --permanent --zone=internal --add-service=mysql
$ sudo firewall-cmd --reload

Without --zone=, the change applies to the default zone (usually public).

Rich Rules — finer-grained #

Plain service / port rules are binary “allow / deny.” When you need only this IP / only at this time / log while allowing, you reach for Rich Rules.

allow SSH only from a specific subnet
$ sudo firewall-cmd --permanent --add-rich-rule='
  rule family="ipv4"
  source address="192.168.64.0/24"
  service name="ssh"
  accept'
$ sudo firewall-cmd --reload
reject a specific IP
$ sudo firewall-cmd --permanent --add-rich-rule='
  rule family="ipv4"
  source address="203.0.113.50"
  reject'
open a port and log access
$ sudo firewall-cmd --permanent --add-rich-rule='
  rule family="ipv4"
  port port="8443" protocol="tcp"
  log prefix="HTTPS-ALT: " level="info"
  accept'

Rich rules get long, so in practice teams keep them in a text file and apply them line by line.

A word on iptables #

Older material is full of iptables -A INPUT .... On RHEL 9, firewalld sits on top, and direct iptables changes will conflict with firewalld and disappear. New folks should use firewalld only.

If firewalld feels heavy for automation, you can run nftables directly. In cloud environments, you’ll usually rely on AWS Security Groups / GCP Firewall at the cloud level over the host firewall. Even so, keeping firewalld on inside the machine adds an extra layer of protection.

SSH hardening — the standard four #

SSH is the most-targeted service on the public side. Port 22 on a public IP sees hundreds of automated attempts per minute. Taking care of all four of these together is the standard.

1) Make a key and register it — before disabling password auth #

First, create an SSH key on your host (the laptop you work from). If you already have one, skip.

on the host — generate a key
$ ssh-keygen -t ed25519 -C "curtis@laptop"
Generating public/private ed25519 key pair.
Enter file in which to save the key (~/.ssh/id_ed25519):
Enter passphrase (empty for no passphrase):  ← strong passphrase recommended
...
Your identification has been saved in ~/.ssh/id_ed25519
Your public key has been saved in ~/.ssh/id_ed25519.pub

ed25519 is the recommended algorithm now. If RSA is more familiar, -t rsa -b 4096 works too.

Copy the public key to the RHEL machine:

on the host — copy
$ ssh-copy-id curtis@192.168.64.15

ssh-copy-id automatically appends the public key to ~/.ssh/authorized_keys and sets the right permissions (the SSH permissions piece from #5).

2) Split into sshd_config.d #

RHEL 9’s sshd reads .conf files in /etc/ssh/sshd_config.d/ before the main file. Keeping your changes in a separate file there avoids conflicts when the package is updated.

/etc/ssh/sshd_config.d/01-hardening.conf
# Disable password auth — keys only
PasswordAuthentication no
KbdInteractiveAuthentication no

# Block SSH login as root
PermitRootLogin no

# (Optional) move off the standard port 22 — cuts auto-bot noise in half
# Port 2222

# Block empty passwords
PermitEmptyPasswords no

# X11 / agent forwarding — close them if not needed
X11Forwarding no
AllowAgentForwarding no

# Auth attempts / concurrent unauthenticated connections
MaxAuthTries 3
MaxStartups 10:30:60

3) Validate, then apply #

A bad config line means your current SSH session stays alive but new SSH sessions can’t connect — you’re locked out (and stuck if you don’t have console access). Always validate first.

syntax check
$ sudo sshd -t       # no output means OK
apply
$ sudo systemctl reload sshd

Keep your current SSH session open, open a new terminal, and SSH in to confirm it works. If it doesn’t, revert from the live session. If the new SSH connects, you’re safe.

verify
$ ssh curtis@192.168.64.15           # in a new terminal
[curtis@rhel9-lab ~]$                # logged in via key (no password prompt)

4) When changing ports — touch firewalld and SELinux too #

If you change the port, firewalld and SELinux need updates as well.

when moving to port 2222
# 1) Add 'Port 2222' to sshd_config.d

# 2) Tell SELinux this port is for ssh
$ sudo dnf install -y policycoreutils-python-utils
$ sudo semanage port -a -t ssh_port_t -p tcp 2222

# 3) Open it in firewalld (keep 22 around for verification, then close)
$ sudo firewall-cmd --permanent --add-port=2222/tcp
$ sudo firewall-cmd --reload

# 4) reload sshd + verify in a new terminal
$ sudo systemctl reload sshd
$ ssh -p 2222 curtis@192.168.64.15

# 5) After confirming the new port works, close port 22
$ sudo firewall-cmd --permanent --remove-service=ssh
$ sudo firewall-cmd --reload

Important — port changes aren’t real security; they just cut bot noise. The actual protection comes from key auth + password disabled + root disabled. The port change is optional.

fail2ban — one more layer #

If automated bots banging the door bothers you, fail2ban can auto-ban them. Available from EPEL.

install / start
$ sudo dnf install -y fail2ban
$ sudo systemctl enable --now fail2ban

The default policy bans an IP for 10 minutes after 5 failed SSH attempts. Detailed tuning is in Advanced #5.

Other items to address #

Since this is the end of the basics series, here’s what’s been covered versus what comes later:

Covered in this series (= sufficient) #

  • #5 — daily-driver user, don’t work as root, the wheel sudo group
  • #3dnf update for regular security patches
  • #7 (this post) — firewalld + SSH hardening

In later series #

TopicSeries
SELinux in depth — labels, booleans, policiesIntermediate #1
auditd / OpenSCAP / FIPS complianceAdvanced #5
Automated security patches (dnf-automatic)Intermediate #6
TLS certificate operationsPractice #1

One line on SELinux — RHEL 9 ships with SELinux Enforcing by default. It’s the last security layer; don’t turn it off. About half of “why won’t nginx bind to port 80?” issues are SELinux label problems, and Intermediate #1 covers the troubleshooting patterns. If you must temporarily relax it, only go as far as setenforce 0 (Permissive). Fully disabling it (disabled in /etc/selinux/config) is never acceptable in production.

AlmaLinux / Rocky differences #

Every command in this post works as is. firewalld / sshd / SELinux are RHEL packages used directly, so there’s no difference. fail2ban from EPEL is also identical.

Common pitfalls #

“My firewalld rule disappeared” #

You skipped --permanent and ran --reload. To keep the change, --permanent and --reload are a pair.

“SSH disconnected and won’t reconnect” #

You reloaded a broken sshd config without console access. On a VM, get to the VM console and revert. In the cloud, use Serial Console or stop the instance and mount the disk. The rule is: always verify in a new terminal before closing the old session.

“Port is open but I still can’t connect” #

Check three places:

  1. firewalldfirewall-cmd --list-all
  2. SELinuxsemanage port -l | grep <port> to confirm registration
  3. Application binding to 127.0.0.1 instead of 0.0.0.0 — check with ss -tlnp

If any one of the three is missing, external traffic can’t get in.

“I disabled key auth and password auth and now I’m locked out” #

Right before you apply sshd_config.d/01-hardening.conf, log in once via key in a fresh terminal and verify it works, then reload. That’s the golden rule of SSH work.

Common commands at a glance #

CommandWhat it does
firewall-cmd --state / --list-allStatus / current rules
firewall-cmd --get-default-zone / --get-active-zonesZones
firewall-cmd --permanent --add-service=httpAdd a service
firewall-cmd --permanent --add-port=8000/tcpAdd a port
firewall-cmd --permanent --add-rich-rule='...'Rich rule
firewall-cmd --reloadPersistent → runtime
firewall-cmd --runtime-to-permanentRuntime → persistent
ssh-keygen -t ed25519Generate a key
ssh-copy-id user@hostRegister the public key
sshd -tsshd config syntax check
systemctl reload sshdReload sshd (no disconnect)
semanage port -a -t ssh_port_t -p tcp 2222Register a port for SSH in SELinux

Wrap-up #

The picture from this post:

  • RHEL’s firewall abstraction is firewalld — interfaces grouped into zones, policy per zone.
  • The two firewall-cmd dimensions — --permanent (config) + --reload (apply to runtime). Or --runtime-to-permanent the other way.
  • service / port / rich rule, three layers of progressively finer control.
  • The four standard SSH hardening steps — register a key → disable password auth → block root → (optional) change the port.
  • Every sshd change follows sshd -t → reload → verify in a new terminal.
  • Don’t disable SELinux. Deep dive in Intermediate #1.
  • One line of automated bot defense from EPEL with fail2ban.

Closing the series #

The seven posts laid out:

  • #1 — Map of the Red Hat family / where RHEL sits / AlmaLinux/Rocky
  • #2 — Booting a single RHEL machine and registering it
  • #3 — Installing, removing, and rolling back packages with dnf / AppStream and modules
  • #4 — Running services with systemd / writing your first unit / journalctl
  • #5 — Users / groups / permissions / chmod / ACL / sudo
  • #6 — Adding a new disk on XFS, mounting it, and registering in /etc/fstab
  • #7 — firewalld zones and SSH hardening

That’s enough to run a single RHEL machine on your own. Sitting in front of a corporate server or SSHing into one shouldn’t trip you up on the basics anymore.

The next steps go deeper.

SeriesWhat
RHEL IntermediateDeep SELinux, LVM, Stratis, NFS, NetworkManager, intro to Podman
RHEL AdvancedBoot / kernel tuning / performance / OpenSCAP / Cockpit
RHEL PracticeWeb / DB / Podman ops, Cockpit, Ansible automation
RHCSA / RHCECert tracks — exam-domain-based

This series is the launch pad for those tracks. Pick whichever fits your environment.

X