--- title: "SSH Hardening Fleet-Wide with Ansible" domain: selfhosting category: security tags: [ssh, ansible, security, hardening, fleet] status: published created: 2026-04-17 updated: 2026-04-17 --- # SSH Hardening Fleet-Wide with Ansible ## Overview Default SSH daemon settings on both Ubuntu and Fedora/RHEL are permissive. A drop-in configuration file (`/etc/ssh/sshd_config.d/99-hardening.conf`) lets you tighten settings without touching the distro-managed base config — and Ansible can deploy it atomically across every fleet host with a single playbook run. ## Settings to Change | Setting | Default | Hardened | Reason | |---|---|---|---| | `PermitRootLogin` | `yes` | `without-password` | Prevent password-based root login; key auth still works for Ansible | | `X11Forwarding` | `yes` | `no` | Nothing in a typical homelab fleet uses X11 tunneling | | `AllowTcpForwarding` | `yes` | `no` | Eliminates a tunneling vector if a service account is compromised | | `MaxAuthTries` | `6` | `3` | Cuts per-connection brute-force attempts in half | | `LoginGraceTime` | `120` | `30` | Reduces the window for slow-connect attacks | ## The Drop-in Approach Rather than editing `/etc/ssh/sshd_config` directly (which may be managed by the distro or overwritten on upgrades), place overrides in `/etc/ssh/sshd_config.d/99-hardening.conf`. The `Include /etc/ssh/sshd_config.d/*.conf` directive in the base config loads these in alphabetical order, and **first match wins** — so `99-` ensures your overrides come last and take precedence. > **Fedora/RHEL gotcha:** Fedora ships `/etc/ssh/sshd_config.d/50-redhat.conf` which sets `X11Forwarding yes`. Because first-match-wins applies, `50-redhat.conf` loads before `99-hardening.conf` and wins. You must patch `50-redhat.conf` in-place before deploying your drop-in, or the X11Forwarding setting will be silently ignored. ## Ansible Playbook ```yaml - name: Harden SSH daemon fleet-wide hosts: all:!raspbian become: true gather_facts: true tasks: - name: Ensure sshd_config.d directory exists ansible.builtin.file: path: /etc/ssh/sshd_config.d state: directory owner: root group: root mode: '0755' - name: Ensure Include directive is present in sshd_config ansible.builtin.lineinfile: path: /etc/ssh/sshd_config line: "Include /etc/ssh/sshd_config.d/*.conf" insertbefore: BOF state: present # Fedora only: neutralize 50-redhat.conf's X11Forwarding yes # (first-match-wins means it would override our 99- drop-in) - name: Comment out X11Forwarding in 50-redhat.conf (Fedora) ansible.builtin.replace: path: /etc/ssh/sshd_config.d/50-redhat.conf regexp: '^(X11Forwarding yes)' replace: '# \1 # disabled by ansible hardening' when: ansible_os_family == "RedHat" ignore_errors: true - name: Deploy SSH hardening drop-in ansible.builtin.copy: dest: /etc/ssh/sshd_config.d/99-hardening.conf content: | # Managed by Ansible — do not edit manually PermitRootLogin without-password X11Forwarding no AllowTcpForwarding no MaxAuthTries 3 LoginGraceTime 30 owner: root group: root mode: '0644' notify: Reload sshd - name: Verify effective SSH settings ansible.builtin.command: cmd: sshd -T register: sshd_effective changed_when: false - name: Assert hardened settings are active ansible.builtin.assert: that: - "'permitrootlogin without-password' in sshd_effective.stdout" - "'x11forwarding no' in sshd_effective.stdout" - "'allowtcpforwarding no' in sshd_effective.stdout" - "'maxauthtries 3' in sshd_effective.stdout" - "'logingracetime 30' in sshd_effective.stdout" fail_msg: "One or more SSH hardening settings not effective — check for conflicting config" when: not ansible_check_mode handlers: - name: Reload sshd ansible.builtin.service: # Ubuntu/Debian: 'ssh' | Fedora/RHEL: 'sshd' name: "{{ 'ssh' if ansible_os_family == 'Debian' else 'sshd' }}" state: reloaded ``` ## Edge Cases **Ubuntu vs Fedora service name:** The SSH daemon is `ssh` on Debian/Ubuntu and `sshd` on Fedora/RHEL. The handler uses `ansible_os_family` to pick the right name automatically. **Missing Include directive:** Some minimal installs don't have `Include /etc/ssh/sshd_config.d/*.conf` in their base config. The `lineinfile` task adds it if absent. Without this, the drop-in directory exists but is never loaded. **Fedora's 50-redhat.conf:** Sets `X11Forwarding yes` with first-match priority. The playbook patches it before deploying the drop-in. **`sshd -T` in check mode:** `sshd -T` reads the *current* running config, not the pending changes. The assert task is guarded with `when: not ansible_check_mode` to prevent false failures during dry runs. **PermitRootLogin on hosts that already had it set:** Some hosts (e.g., those managed by another tool) may already have `PermitRootLogin without-password` set elsewhere. The drop-in still applies cleanly — it just becomes a no-op for that setting. ## Verify Manually ```bash # Check effective settings on any host ssh root@ "sshd -T | grep -E 'permitrootlogin|x11forwarding|allowtcpforwarding|maxauthtries|logingracetime'" # Expected: # permitrootlogin without-password # x11forwarding no # allowtcpforwarding no # maxauthtries 3 # logingracetime 30 ``` ## See Also - [linux-server-hardening-checklist](linux-server-hardening-checklist.md) - [ansible-unattended-upgrades-fleet](ansible-unattended-upgrades-fleet.md) - [ufw-firewall-management](ufw-firewall-management.md)