RHEL Basics #7: Basic Security — firewalld, SSH Hardening
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:
- #1 What is RHEL — From Fedora to RHEL, plus AlmaLinux and Rocky Linux
- #2 Setup — Installing RHEL 9, Subscription Manager, first login
- #3 dnf and package management — repo, modules, AppStream
- #4 Intro to systemd — services, targets, journalctl
- #5 Users / groups / permissions — UID/GID, sudo, ACL
- #6 Filesystem basics — XFS, mount, /etc/fstab
- #7 Basic security — firewalld, SSH hardening ← this post
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.
$ sudo systemctl status firewalld
● firewalld.service - firewalld - dynamic firewall daemon
Active: active (running)
...
$ sudo firewall-cmd --state
runningOn 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.
eth0 (external) ──→ public zone → strict policy
eth1 (internal) ──→ internal zone → loose policy
wg0 (VPN) ──→ trusted zone → almost everything allowedPredefined zones:
| zone | Description |
|---|---|
drop | Strictest. Drops incoming traffic with no response |
block | Like drop but sends a reject response |
public | Default — only some services allowed |
external | NAT (masquerading) on |
dmz | For DMZ servers |
work / home / internal | Desktop trust levels |
trusted | Allow all traffic |
View current zones and active rules #
$ 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:
| Option | Where it applies |
|---|---|
| (none) | Runtime only — gone after reboot or reload |
--permanent | Persistent config only — applies after a reload |
The standard pattern is almost always:
$ sudo firewall-cmd --permanent --add-service=http # add to persistent config
$ sudo firewall-cmd --reload # reload persistent config into runtimeOr in a single shot:
$ 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.
$ 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 httpsListing the predefined services:
$ 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:
$ 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/tcpPer-zone #
You can drop --zone= anywhere in the command:
$ sudo firewall-cmd --permanent --zone=internal --add-service=mysql
$ sudo firewall-cmd --reloadWithout --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.
$ 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$ sudo firewall-cmd --permanent --add-rich-rule='
rule family="ipv4"
source address="203.0.113.50"
reject'$ 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
nftablesdirectly. 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.
$ 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.pubed25519 is the recommended algorithm now. If RSA is more familiar, -t rsa -b 4096 works too.
Copy the public key to the RHEL machine:
$ ssh-copy-id curtis@192.168.64.15ssh-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.
# 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:603) 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.
$ sudo sshd -t # no output means OK$ sudo systemctl reload sshdKeep 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.
$ 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.
# 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 --reloadImportant — 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.
$ sudo dnf install -y fail2ban
$ sudo systemctl enable --now fail2banThe 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
- #3 —
dnf updatefor regular security patches - #7 (this post) — firewalld + SSH hardening
In later series #
| Topic | Series |
|---|---|
| SELinux in depth — labels, booleans, policies | Intermediate #1 |
| auditd / OpenSCAP / FIPS compliance | Advanced #5 |
Automated security patches (dnf-automatic) | Intermediate #6 |
| TLS certificate operations | Practice #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 (disabledin/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:
- firewalld —
firewall-cmd --list-all - SELinux —
semanage port -l | grep <port>to confirm registration - 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 #
| Command | What it does |
|---|---|
firewall-cmd --state / --list-all | Status / current rules |
firewall-cmd --get-default-zone / --get-active-zones | Zones |
firewall-cmd --permanent --add-service=http | Add a service |
firewall-cmd --permanent --add-port=8000/tcp | Add a port |
firewall-cmd --permanent --add-rich-rule='...' | Rich rule |
firewall-cmd --reload | Persistent → runtime |
firewall-cmd --runtime-to-permanent | Runtime → persistent |
ssh-keygen -t ed25519 | Generate a key |
ssh-copy-id user@host | Register the public key |
sshd -t | sshd config syntax check |
systemctl reload sshd | Reload sshd (no disconnect) |
semanage port -a -t ssh_port_t -p tcp 2222 | Register 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-cmddimensions —--permanent(config) +--reload(apply to runtime). Or--runtime-to-permanentthe 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.
| Series | What |
|---|---|
| RHEL Intermediate | Deep SELinux, LVM, Stratis, NFS, NetworkManager, intro to Podman |
| RHEL Advanced | Boot / kernel tuning / performance / OpenSCAP / Cockpit |
| RHEL Practice | Web / DB / Podman ops, Cockpit, Ansible automation |
| RHCSA / RHCE | Cert tracks — exam-domain-based |
This series is the launch pad for those tracks. Pick whichever fits your environment.