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

  1. Run with --check --diff first, and read what will change.
  2. 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.
  3. Change ansible_user to the admin user (with become: true) for later runs.
  4. If you change the SSH port, update UFW and your inventory (ansible_port) in the same run.
  5. 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.

  • Ansible
  • Security
  • Linux