SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Secure a new Rocky Linux server

The first hour on a Rocky Linux or AlmaLinux VPS: dnf updates, a wheel user, key only SSH, firewalld, dnf-automatic, and SELinux left enforcing.

What the first hour on a new Rocky Linux server looks like

To secure a new Rocky Linux server, six things need doing before you install anything else: a full dnf upgrade, an administrative user in the wheel group, key-only SSH, a firewalld zone that opens only the services you run, automatic updates through dnf-automatic, and SELinux left in enforcing mode. Every command below works the same on AlmaLinux, because both are rebuilds of the same Red Hat Enterprise Linux sources. Run them yourself on a box you can still reach another way, and keep a second SSH session open from the moment you touch SSH or the firewall.

Which Rocky Linux release to pin to

This was written against Rocky Linux 10.2, released on 29 May 2026, and the same commands apply on Rocky Linux 9 and on AlmaLinux 10. Rocky 10 still ships DNF 4.20 as dnf, so the flags you already know behave the way you expect. Start a new server on the 10 branch rather than on 8: the 8 branch carries the oldest Python and OpenSSL of the supported branches, and every year you keep it is another year of working around that. Confirm what you actually booted before you trust any guide, including this one.

cat /etc/rocky-release
uname -r

The release file prints something like Rocky Linux release 10.2, and 10.2 shipped a 6.12 kernel. On AlmaLinux the file is /etc/almalinux-release, and /etc/os-release exists on both. If you have not committed to either yet, the practical differences between Rocky Linux and AlmaLinux are smaller than the marketing suggests, and nothing on this page changes between them.

Update everything before you touch anything else

sudo dnf upgrade --refresh -y
sudo dnf install -y dnf-plugins-core
sudo dnf needs-restarting -r

--refresh discards cached repository metadata. That matters on a provider image built weeks before you booted it, because dnf will otherwise reuse a stale index and report the system as current when it is not. needs-restarting -r then answers the question that follows: it prints Reboot is required to fully utilize these updates when the kernel or a core library changed, and Reboot should not be necessary when nothing did. Reboot now, while nothing is running on the machine and nobody depends on it.

Create an administrative user in the wheel group

Logging in as root over SSH means one stolen key is a total loss, and every scanner on the internet already knows that username. Create a normal account, put it in wheel, and reach root through sudo.

sudo useradd -c 'admin account' deploy
sudo passwd deploy
sudo usermod -aG wheel deploy
id deploy

Two Rocky details will surprise you if your habits come from Debian. useradd creates the home directory on its own here, so there is no -m to remember, and adduser is a symbolic link to useradd rather than the interactive script Debian ships. id deploy must list wheel among the groups. That group is the whole grant: Red Hat's default /etc/sudoers carries the line %wheel ALL=(ALL) ALL, so membership is what gives the account root, and sudo grep -n '^%wheel' /etc/sudoers proves the line is really active and not commented out. Who ran what through sudo afterwards is worth recording as well, and auditing user commands on your server covers that side of it.

Now copy your public key up from your own machine, while password login still works.

ssh-copy-id deploy@203.0.113.10
ssh deploy@203.0.113.10

The second command should drop you at a shell without asking for a password. Run sudo -v in that session. It asks for the account password you just set and returns silently if the wheel membership took effect. Leave that session open.

Why key login fails on Rocky when the file looks correct

ssh-copy-id gets the SELinux label right, because it writes the file as the user who owns it. Building authorized_keys by hand often does not, and this is the Red Hat family trap with no Ubuntu equivalent. Create /home/deploy/.ssh as root, or copy a file in from /tmp or from /root, and the file carries the wrong SELinux label. sshd is then refused permission to read it, key authentication fails, and ls -l shows nothing wrong at all.

ls -Z /home/deploy/.ssh/authorized_keys
sudo restorecon -Rv /home/deploy/.ssh
sudo ausearch -m AVC -ts recent

ls -Z should show the type ssh_home_t. A file that reads user_home_t, or admin_home_t after a copy out of /root, is the bug, and restorecon relabels it from policy. ausearch prints the matching denial as an avc: denied { read } line naming comm="sshd", which is how you tell an SELinux problem from a permissions problem. Ordinary permissions still apply too: the directory must be mode 700 and the file 600, or sshd writes Authentication refused: bad ownership or modes for file to the journal.

Turn off password logins the way Rocky reads sshd config

/etc/ssh/sshd_config on Rocky 9 and 10 begins by pulling in a directory of drop-in files.

Include /etc/ssh/sshd_config.d/*.conf

sshd keeps the first value it finds for any keyword, and that include sits at the top of the file, so a drop-in beats the main config, and among the drop-ins the filename that sorts first wins. Provider and cloud images often leave a 50-cloud-init.conf in that directory setting PasswordAuthentication yes. A file numbered above 50 loses to it silently. Look first, then write a file that sorts earlier.

ls -l /etc/ssh/sshd_config.d/
sudo tee /etc/ssh/sshd_config.d/01-hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
EOF
sudo chmod 600 /etc/ssh/sshd_config.d/01-hardening.conf
sudo sshd -t

KbdInteractiveAuthentication is the line most guides leave out. PasswordAuthentication no closes the plain password path, and PAM keyboard-interactive is a second route to the same password prompt, so leaving it enabled undoes the change you just made. sshd -t parses the config and prints nothing when it is valid. Then ask the daemon what it actually decided, instead of reading your own file back.

sudo sshd -T | grep -E '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication) '

All four lines should match what you wrote. If passwordauthentication yes comes back, another drop-in is winning, and the ls above shows you which one. Only then reload, and only with your first session still open.

sudo systemctl reload sshd

Open a second terminal and log in before you close the first. Recovering a locked-out VPS means the provider's console, which is slower than every other option. There is more to change in sshd_config than these four lines, and hardening SSH on a VPS works through the rest.

Open only the services you run with firewalld

Rocky ships firewalld installed and enabled, which is the main difference from the Ubuntu version of this checklist, where ufw is present but inactive. Provider images vary, so read the state before you change it.

sudo firewall-cmd --state
sudo firewall-cmd --get-default-zone
sudo firewall-cmd --list-all

--state prints running or not running. The default zone is public on a stock image, and --list-all prints the services allowed in it. ssh is one of them, which is why you could log in at all. Add what you actually serve, and nothing more.

sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload
sudo firewall-cmd --list-services

The --permanent flag writes the zone file and changes nothing in the running firewall. A rule added without it changes the running firewall and disappears at the next --reload or reboot. Forgetting the --reload is why a rule you are certain you added does not appear in --list-services. If you would rather test before you commit, add the rule without --permanent, confirm the service answers, then run sudo firewall-cmd --runtime-to-permanent. Zones and rich rules go a long way past this, and firewalld basics for a VPS is where that belongs.

One thing the firewall on the box cannot show you: most providers run a second packet filter in their own network, in front of your server. A port that firewall-cmd --list-all reports as open and that still refuses outside connections is usually blocked there, so check the panel before you debug anything on the server.

EPEL and CRB, only when a package needs them

Rocky's own repositories are deliberately small, and two extras fill the gaps. CRB, the CodeReady Builder repository, holds development headers and libraries the base repositories leave out. EPEL, Extra Packages for Enterprise Linux, is the Fedora project's build of community software for this family, and it is where fail2ban and several thousand other packages live.

sudo dnf config-manager --set-enabled crb
sudo dnf install -y epel-release
sudo dnf repolist

crb is the repository id on Rocky and AlmaLinux. Rocky 8 called the same repository powertools, and on Red Hat Enterprise Linux itself it is codeready-builder-for-rhel-10-x86_64-rpms, which is why copied instructions fail with Error: No matching repo to modify: crb. The epel-release package lives in Rocky's extras repository, which is enabled already, so there is no key to import by hand. Enable both only when something you want needs them, because every extra repository is one more place your automatic updates will now pull from. Enabling EPEL and CRB on Rocky and AlmaLinux covers priorities and pinning.

Automatic security updates with dnf-automatic

None of the above survives the next three weeks if the box stops taking patches. Rocky has no unattended-upgrades. The equivalent is dnf-automatic, which is one config file plus a systemd timer, and Rocky 10.2 ships version 4.20.

sudo dnf install -y dnf-automatic

Edit /etc/dnf/automatic.conf. These are the keys that decide what happens.

[commands]
upgrade_type = default
random_sleep = 3600
download_updates = yes
apply_updates = yes
reboot = never

[emitters]
emit_via = stdio

apply_updates is no in the file as shipped. That is the trap in this whole section: install the package, enable the timer, walk away, and the machine downloads updates every morning and installs none of them. Set it to yes, then check later that a run really did something.

sudo systemctl enable --now dnf-automatic.timer
systemctl list-timers dnf-automatic.timer
journalctl -u dnf-automatic.service --since -7d

list-timers should show a NEXT time inside the next day, and the journal is where a failed or empty run explains itself. Two further details are easy to get wrong. The package also ships dnf-automatic-install.timer, dnf-automatic-download.timer and dnf-automatic-notifyonly.timer, and those units deliberately override your config file with their own fixed behaviour, so enable dnf-automatic.timer and no other, or the file you just edited is ignored.

The second is upgrade_type. Setting it to security sounds like the careful choice, and on a rebuild distribution it is the riskier one, because it filters on advisory metadata (updateinfo.xml) published alongside the packages, and Rocky's metadata has been reported stale on some branches. Test it on your own box before you depend on it.

sudo dnf updateinfo list --available --security

An empty list on a system that has pending updates means the metadata is not marking anything as a security fix, so upgrade_type = security would install nothing at all. default upgrades everything, which on an enterprise-stable branch is a small and well-tested set of changes. Running dnf-automatic on Rocky and AlmaLinux covers the emitters, the reboot settings and how to hold a package back.

Do you need a brute-force guard on a key-only server?

With PasswordAuthentication no and KbdInteractiveAuthentication no in place, an SSH brute-force attempt cannot succeed, because there is no password for it to guess. What remains is log noise and a little CPU spent rejecting connections. That is the honest case for fail2ban on one small server: it makes journalctl -u sshd readable again. Run any password-authenticated service on the box and the case becomes much stronger.

fail2ban comes from EPEL, so enable that first. Rocky 10 gets version 1.1.0.

sudo dnf install -y fail2ban-server fail2ban-firewalld fail2ban-selinux fail2ban-systemd

fail2ban-firewalld is the subpackage that matters here. Without it, fail2ban applies bans with iptables commands, which on a firewalld box means two tools writing to the same nftables ruleset with no knowledge of each other. fail2ban-selinux ships the policy module that lets the daemon do its job while SELinux stays enforcing. No jail is enabled by default, and jail.conf is replaced on package updates, so put your settings in a file beside it.

sudo tee /etc/fail2ban/jail.d/sshd.local >/dev/null <<'EOF'
[sshd]
enabled = true
backend = systemd
maxretry = 5
findtime = 10m
bantime = 1h
EOF
sudo systemctl enable --now fail2ban
sudo fail2ban-client status sshd

backend = systemd reads the journal instead of /var/log/secure, which is the right choice on a minimal image, because the journal is always present and the log file only exists when rsyslog is installed. fail2ban-client status sshd should print the jail with its filter and action sections, plus a list of banned addresses that starts empty. On a public IP it does not stay empty for long. Confirm that a ban actually reaches the firewall with sudo firewall-cmd --list-rich-rules. Keep bantime in hours rather than days, and add your own fixed address to ignoreip in the same file, because the first person a misconfigured jail locks out is usually you.

Leave SELinux enforcing, and the one semanage step a port change needs

The most repeated piece of Rocky advice on the internet is to set SELINUX=disabled and reboot. Do not. SELinux is a large part of why you would pick this family over Ubuntu, the shipped policy is written for the software in these repositories, and a default install of nginx or podman runs under it without a single change. Find out where you are.

getenforce
sudo sestatus

getenforce should print Enforcing. Some provider images ship Permissive, which logs denials and blocks nothing, so read the value rather than assuming it. When something does break, the denial is already written down for you.

sudo ausearch -m AVC -ts recent

The one place first-hour hardening runs into the policy is moving SSH off port 22. The sshd policy permits binding to ports labelled ssh_port_t, and 22 is the only port carrying that label. Set Port 2222 without doing anything else and the daemon refuses to start, with this in journalctl -u sshd:

error: Bind to port 2222 on 0.0.0.0 failed: Permission denied.

The fix is one command, plus installing the tool that provides it.

sudo dnf install -y policycoreutils-python-utils
sudo semanage port -a -t ssh_port_t -p tcp 2222
sudo semanage port -l | grep ssh_port_t
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload

semanage is not installed on Rocky 10 by default, so the error most people hit first is semanage: command not found rather than anything that mentions SELinux. The full sequence, including keeping port 22 open until you have proved the new one works, is in changing the SSH port with SELinux and firewalld. For contexts, booleans and what to do when a service you install does hit the policy, SELinux basics for a server is the deeper treatment.

What I would skip on a single small VPS

Not all of this earns its place on one small machine.

  • Keep SELinux enforcing. It costs nothing to leave alone, and the one semanage command above handles the only case you are likely to meet in the first hour.
  • Keep dnf-automatic with apply_updates = yes. An unpatched server is the failure that actually happens to people.
  • Skip fail2ban when SSH is the only listening service and it is key-only. It buys quieter logs, at the price of another daemon and another way to lock yourself out. systemctl status fail2ban prints the unit's memory use, so you can see what it costs on your own box.
  • Skip EPEL and CRB until a package you want needs them. Enabling a repository is a lasting decision about where your updates come from.
  • Skip moving the SSH port. It cuts scanner noise in the log. It stops nobody who is looking at your server on purpose.

The one thing never to skip is the boring one: keep a second SSH session open whenever you change SSH or the firewall, and learn how to reach your provider's serial console before the day you need it. Coming from Debian or Ubuntu, the dnf and apt command equivalents rebuild the muscle memory quickly, and unattended-upgrades on Ubuntu is this same job on that side. The distribution-neutral version of this list is the first ten minutes on a new VPS, and least privilege user accounts on a VPS takes the account question further than one member of wheel.

FAQ

Should I disable SELinux on a Rocky Linux server?

No. A denial is a message, and it names the file or the port that the policy did not expect. Read it with sudo ausearch -m AVC -ts recent, then fix the label with restorecon or set the boolean the service needs. If you must loosen it, set SELINUX=permissive in /etc/selinux/config while you debug, which logs denials and blocks nothing. Avoid SELINUX=disabled, because a disabled system stops maintaining file labels, so returning to enforcing later forces a full relabel of the disk on the next boot.

Do I still need fail2ban if SSH is key only?

Not for SSH itself. With PasswordAuthentication no and KbdInteractiveAuthentication no set, and confirmed in sudo sshd -T, a brute-force attempt has no password to guess and cannot succeed however many times it tries. What fail2ban gives you there is a quieter journal and slightly less CPU spent on rejected connections. It becomes worth running as soon as something else on the box accepts passwords, such as a mail server or a web application login.

Why does key login fail after I created the .ssh directory as root?

Because the file carries the wrong SELinux label and sshd is refused permission to read it. Check with ls -Z /home/deploy/.ssh/authorized_keys. The type should be ssh_home_t, and a file created as root or copied out of /root usually reads admin_home_t instead. Run sudo restorecon -Rv /home/deploy/.ssh to relabel it from policy. Confirm the diagnosis with sudo ausearch -m AVC -ts recent, which prints an avc: denied { read } line naming comm="sshd". Check the ordinary permissions as well: 700 on the directory, 600 on the file.

What replaces unattended-upgrades on Rocky Linux?

dnf-automatic, installed with sudo dnf install -y dnf-automatic and driven by /etc/dnf/automatic.conf plus dnf-automatic.timer. Set apply_updates = yes in the [commands] section, because the shipped default is no and the timer will otherwise download updates forever without installing any of them. Enable dnf-automatic.timer and no other unit, since the extra timers the package ships override your config file with their own fixed behaviour.

Should I start a new server on Rocky Linux 8?

Prefer the 10 branch, or 9 when something you depend on has no build for 10 yet. Rocky 8 carries the oldest Python and OpenSSL of the supported branches, so newer software increasingly ships without a package for it, and you end up compiling from source or adding third-party repositories on the machine you were trying to keep simple. Check what a running box actually is with cat /etc/rocky-release, because provider images labelled "Rocky Linux" are sometimes a branch older than the panel suggests.