Hardening a server by hand works once. For every new VPS, you’d repeat the same twenty steps, and inevitably miss one. Put the baseline into an Ansible playbook, and every server gets the same, reviewed security configuration in a minute, and stays that way when you re-run it.
What the playbook implements: Linux server hardening checklist.
Inventory and variables
# inventory.ini
[vps]
web1 ansible_host=203.0.113.10
web2 ansible_host=203.0.113.11
[vps:vars]
ansible_user=root
# group_vars/vps.yml
admin_user: deploy
admin_ssh_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
ssh_port: 22
allowed_tcp_ports: [80, 443]
The first run connects as root (or the provider’s initial user). Afterwards, you connect as the admin user. See Ansible inventory.
The playbook
# harden.yml
- hosts: vps
become: true
tasks:
- name: Update apt cache and upgrade packages
ansible.builtin.apt:
update_cache: true
upgrade: safe
- name: Install baseline packages
ansible.builtin.apt:
name: [ufw, fail2ban, unattended-upgrades, chrony, sudo]
state: present
- name: Admin user
ansible.builtin.user:
name: "{{ admin_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Admin SSH key
ansible.posix.authorized_key:
user: "{{ admin_user }}"
key: "{{ admin_ssh_key }}"
- name: Passwordless sudo for admin (optional; or require a password)
ansible.builtin.copy:
dest: "/etc/sudoers.d/{{ admin_user }}"
content: "{{ admin_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: visudo -cf %s
- name: SSH hardening drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/10-hardening.conf
content: |
Port {{ ssh_port }}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
X11Forwarding no
mode: "0644"
validate: sshd -t -f %s
notify: Restart ssh
- name: UFW defaults
community.general.ufw:
default: "{{ item.policy }}"
direction: "{{ item.dir }}"
loop:
- { dir: incoming, policy: deny }
- { dir: outgoing, policy: allow }
- name: Allow SSH
community.general.ufw:
rule: limit
port: "{{ ssh_port }}"
proto: tcp
- name: Allow service ports
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: "{{ allowed_tcp_ports }}"
- name: Enable UFW
community.general.ufw:
state: enabled
- name: Fail2ban SSH jail
ansible.builtin.copy:
dest: /etc/fail2ban/jail.d/sshd.local
content: |
[sshd]
enabled = true
port = {{ ssh_port }}
maxretry = 5
bantime = 1h
notify: Restart fail2ban
- name: Automatic security updates
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
content: |
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
- name: Kernel network hardening
ansible.posix.sysctl:
name: "{{ item.key }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/90-hardening.conf
loop: "{{ {'net.ipv4.conf.all.rp_filter': 1, 'net.ipv4.conf.all.accept_redirects': 0, 'net.ipv4.conf.all.send_redirects': 0, 'net.ipv4.tcp_syncookies': 1, 'kernel.kptr_restrict': 2} | dict2items }}"
handlers:
- name: Restart ssh
ansible.builtin.service: { name: ssh, state: restarted }
- name: Restart fail2ban
ansible.builtin.service: { name: fail2ban, state: restarted }
Install the needed collections first: ansible-galaxy collection install community.general ansible.posix.
Roll out without locking yourself out
- Run with
--check --difffirst, and read what will change. - Make sure the admin key works before disabling root and password logins. Run the playbook once, then test
ssh [email protected]in a new terminal. - Change
ansible_userto the admin user (withbecome: true) for later runs. - If you change the SSH port, update UFW and your inventory (
ansible_port) in the same run. - Keep the VNC console of your VPS control panel as a fallback. See VPS VNC console.
ansible-playbook -i inventory.ini harden.yml --check --diff
ansible-playbook -i inventory.ini harden.yml
Make it a role, and keep it running
Move the tasks into a hardening role, and apply it from every project’s playbook. Re-run it regularly (or from CI), so manual changes drift back to the baseline. See Ansible roles.
Ready-made collections exist too (for example devsec.hardening, implementing CIS-style controls). Review them carefully before applying to production.
Docker caveat
UFW doesn’t filter Docker-published ports. Handle those separately. See Docker and UFW.
Quick reference
| Area | Module |
|---|---|
| Users and keys | user, authorized_key |
| sudo | copy + validate: visudo -cf %s |
| SSH | sshd_config.d drop-in + validate: sshd -t -f %s |
| Firewall | community.general.ufw |
| Updates | unattended-upgrades config |
| Kernel | ansible.posix.sysctl |
Related: Ansible Vault and Fail2ban.