
Harden SSH Across a Fleet with Ansible
Before you run this
This playbook drops a single sshd configuration file (/etc/ssh/sshd_config.d/99-hardening.conf) onto every host in your inventory and reloads the SSH service. It disables root login, disables password authentication, requires public-key auth, and tightens a handful of session and auth limits.
- Privileges: it needs
become: true(root) on the managed hosts to write into/etc/sshand reload the service. Your control-node user needs the usual SSH access and sudo rights on the fleet. - This can lock you out. Two settings in particular —
PasswordAuthentication noandAllowGroups— will end your ability to log in if you get them wrong. Do not enablePasswordAuthentication nountil key-based login is already working for you, and do not setAllowGroupsto a group your admin account is not a member of. - Test on one host first. Read the config below, then run the play against a single non-production host with
--limit, and — critically — keep your existing SSH session open while you open a second session to confirm you can still get in. Only when that second login succeeds should you roll it out wider. - The change is reversible: the playbook keeps a timestamped backup of any previous file, and undoing it is just deleting the drop-in and reloading (shown at the end). A bad reload only takes effect on reload/restart, so an open session and a held connection are your safety net.
I'm assuming a recent ansible-core (2.14+) on the control node, and managed hosts running Ubuntu 22.04 LTS or RHEL 9 / AlmaLinux 9. Both ship OpenSSH new enough (8.7/8.9) that /etc/ssh/sshd_config already contains Include /etc/ssh/sshd_config.d/*.conf near the top of the file. That "near the top" matters: sshd uses the first value it reads for most keywords, so an included drop-in read early overrides the defaults set later in the main file.
Verify the Include line is present before you rely on this approach:
ansible all -m command -a "grep -n Include /etc/ssh/sshd_config" --become
If a host doesn't have that line, the drop-in won't be read and you'll need to add the setting to the main config instead — check the sshd_config(5) man page for that host's OpenSSH version.
The hardening drop-in
Create templates/99-hardening.conf.j2. Every directive here is a standard sshd_config keyword; the values are the safe, well-established ones.
# Managed by Ansible - do not edit by hand
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
KbdInteractiveAuthentication no
X11Forwarding no
LoginGraceTime 30
MaxAuthTries {{ ssh_max_auth_tries }}
ClientAliveInterval 300
ClientAliveCountMax 2
AllowGroups {{ ssh_allowed_groups }}
A couple of notes:
KbdInteractiveAuthenticationis the current name (OpenSSH 8.7+). On older OpenSSH it wasChallengeResponseAuthentication; on the OS versions assumed here, use the name above.- I've deliberately left the crypto (
Ciphers,MACs,KexAlgorithms) alone. The defaults on OpenSSH 8.7/8.9 are already sound, and a hand-typed cipher list is an easy way to break interoperability or paste a typo. If you do want to pin them, generate the list from Mozilla's OpenSSH guidelines (search "Mozilla Security/Guidelines OpenSSH") rather than inventing values, and add them to this same file.
The playbook
harden-ssh.yml:
---
- name: Harden SSH across the fleet
hosts: all
become: true
vars:
ssh_max_auth_tries: 4
# Replace with a group your admin accounts are actually members of.
ssh_allowed_groups: "ssh-users"
tasks:
- name: Deploy the SSH hardening drop-in
ansible.builtin.template:
src: templates/99-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/99-hardening.conf
owner: root
group: root
mode: "0644"
backup: true # keeps a timestamped copy of any prior file
notify: Reload sshd
- name: Validate the full sshd configuration
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false # this only tests, it never changes anything
handlers:
- name: Reload sshd
ansible.builtin.service:
# Debian/Ubuntu call the unit "ssh"; RHEL family call it "sshd".
name: "{{ 'ssh' if ansible_facts['os_family'] == 'Debian' else 'sshd' }}"
state: reloaded
Why it's ordered this way: the drop-in is written first, then sshd -t validates the whole on-disk config (including the new file) before anything restarts. Handlers don't fire until the end of the play, so if validation fails, the play aborts and the running sshd is never reloaded — your current sessions and config stay exactly as they were. The bad file is left on disk (with the previous version preserved as a .NNNN.backup), so fix or delete it before the service is next restarted.
I use reloaded, not restarted, on purpose: a reload re-reads the config without dropping existing connections, which is another layer of protection against locking yourself out.
Rolling it out safely
Ensure the AllowGroups group exists and your account is in it before you run this. On the target:
getent group ssh-users # does the group exist?
id yourname # are you in it?
Do a dry run to see what would change, then apply to exactly one host while watching:
# See intended changes without touching anything
ansible-playbook harden-ssh.yml --limit test-host-01 --check --diff
# Apply to a single host
ansible-playbook harden-ssh.yml --limit test-host-01
Now, with your current session to test-host-01 still open, open a second terminal and log in again. If that succeeds with your key, roll out to the rest:
ansible-playbook harden-ssh.yml
Verify it worked
The authoritative check is sshd -T, which prints the effective configuration sshd would use. Run it across the fleet and grep for the settings you changed:
ansible all -m command \
-a "sshd -T" --become \
| grep -Ei 'permitrootlogin|passwordauthentication|pubkeyauthentication|allowgroups|maxauthtries'
You should see permitrootlogin no, passwordauthentication no, pubkeyauthentication yes, and your allowgroups line. Because sshd -T reports the running effective config, this confirms both that the file was read and that the reload took effect.
Confirm the service is healthy on each host:
ansible all -m command \
-a "systemctl is-active ssh sshd" --become --ignore-errors
(One of the two unit names will be reported inactive/not-found depending on the OS family — that's expected.)
Undo / rollback
If something's wrong, remove the drop-in and reload. Because the main sshd_config and its defaults are untouched, deleting the file returns the host to its prior behaviour.
---
- name: Roll back SSH hardening
hosts: all
become: true
tasks:
- name: Remove the hardening drop-in
ansible.builtin.file:
path: /etc/ssh/sshd_config.d/99-hardening.conf
state: absent
notify: Reload sshd
- name: Validate the full sshd configuration
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
handlers:
- name: Reload sshd
ansible.builtin.service:
name: "{{ 'ssh' if ansible_facts['os_family'] == 'Debian' else 'sshd' }}"
state: reloaded
For a single locked-out host you can't reach over SSH, use the console (IPMI/iDRAC/iLO, cloud serial console, or hypervisor console) to delete the file, run sshd -t, and systemctl reload ssh (or sshd) by hand.
For the exact meaning and valid values of any directive here, the sshd_config(5) man page for the OpenSSH version on your hosts is the canonical reference; for the Ansible modules used, see the ansible.builtin.template, ansible.builtin.command, and ansible.builtin.service pages in the official Ansible documentation.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
