DEV Community

Pawan Shinde
Pawan Shinde

Posted on

I Replaced My Manual 15-Step Linux Hardening Runbook with Ansible

Whenever I used to spin up a new Linux server, my manual setup routine was always the same: SSH in, update system packages, configure UFW firewall rules, set up Fail2ban jails, write Nginx proxy configurations with security headers, and verify that services were enabled at boot.

Manually running through that checklist took roughly 25 minutes per machine. On a single test box it wasn't a blocker, but repeating those steps across multiple environments quickly became repetitive, tedious, and prone to small human errors—like forgetting a header or mistyping a firewall port.

Industry incident data shows that over 80% of unauthorized access incidents on internet-facing compute nodes stem from basic configuration drift and missed hardening steps, such as unrestricted default ports or unpatched vulnerabilities.

To eliminate manual toil and ensure consistent infrastructure, I decided to automate the entire process using an idempotent Ansible playbook.

Here are my live test notes, configuration files, and terminal benchmarks from running it against a clean Ubuntu 22.04 node.

  • The Automation Target

Instead of typing 15 commands into a shell every time, the playbook declaratively enforces this target state:

  1. System Packages: Update the apt cache, upgrade existing packages, and install core tools (ufw, fail2ban, nginx, curl, htop, logrotate).
  2. Firewall Lockdown: Block all inbound traffic by default, only opening ports 22 (SSH), 80 (HTTP), and 443 (HTTPS).
  3. Brute-Force Protection: Deploy a custom Fail2ban jail that automatically bans an IP for 1 hour after 5 failed SSH authentication attempts within 10 minutes.
  4. Reverse Proxy & SRE Health Check: Configure Nginx with standard security headers (X-Frame-Options, X-Content-Type-Options) and add a /healthz endpoint returning HTTP 200 for monitoring probes.
  5. Strict Idempotency: Re-running the script against an already-configured node makes zero modifications and triggers zero service restarts.

Project Structure

I structured the project into a modular directory:


text
ansible-linux-node-hardener/
├── inventory.ini
├── playbook.yml
├── templates/
│   ├── nginx.conf.j2
│   └── jail.local.j2
└── README.md

1. Inventory & Dynamic Templates
​inventory.ini
​I defined the target test node and variable overrides:
[webservers]
node01 ansible_host=192.168.1.50 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa

[webservers:vars]
http_port=80
server_domain=api.lab.internal

templates/nginx.conf.j2
​A Jinja2 template to configure Nginx to proxy traffic to an internal app on port 8080, enforce security headers, and expose an uptime health check:
server {
    listen {{ http_port }};
    server_name {{ server_domain }};

    # Security headers to prevent clickjacking and MIME-type sniffing
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Content-Type-Options "nosniff" always;

    location / {
        proxy_pass [http://127.0.0.1:8080](http://127.0.0.1:8080);
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_connect_timeout 60s;
        proxy_read_timeout 60s;
    }

    # Lightweight endpoint for uptime/health checks
    location /healthz {
        access_log off;
        return 200 "healthy\n";
    }
}

templates/jail.local.j2
​A local override for Fail2ban to protect SSH access without modifying package-managed files:

[DEFAULT]
bantime  = 1h
findtime = 10m
maxretry = 5

[sshd]
enabled = true
port    = ssh
backend = systemd

2. The Automation Playbook (playbook.yml)
​The playbook runs system updates, installs packages, sets firewall rules, templates configs, and uses handlers so services reload only when configuration files actually change:

---
- name: Automate Linux Node Hardening and Nginx Proxy
  hosts: webservers
  become: true
  gather_facts: true

  vars:
    required_packages:
      - ufw
      - fail2ban
      - nginx
      - curl
      - htop
      - logrotate

  tasks:
    - name: Update apt cache and upgrade system packages
      apt:
        update_cache: yes
        upgrade: dist
        cache_valid_time: 3600

    - name: Install baseline security and proxy packages
      apt:
        name: "{{ required_packages }}"
        state: present

    - name: Configure UFW default policies
      ufw:
        direction: "{{ item.direction }}"
        policy: "{{ item.policy }}"
      loop:
        - { direction: 'incoming', policy: 'deny' }
        - { direction: 'outgoing', policy: 'allow' }

    - name: Allow essential inbound ports
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "22"
        - "80"
        - "443"

    - name: Enable UFW service
      ufw:
        state: enabled

    - name: Deploy Fail2ban configuration
      template:
        src: templates/jail.local.j2
        dest: /etc/fail2ban/jail.local
        owner: root
        group: root
        mode: '0644'
      notify: Restart Fail2ban

    - name: Deploy Nginx reverse proxy configuration
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/default
        owner: root
        group: root
        mode: '0644'
      notify: Reload Nginx

    - name: Ensure baseline services are running and enabled at boot
      systemd:
        name: "{{ item }}"
        state: started
        enabled: yes
      loop:
        - ufw
        - fail2ban
        - nginx

  handlers:
    - name: Restart Fail2ban
      systemd:
        name: fail2ban
        state: restarted

    - name: Reload Nginx
      systemd:
        name: nginx
        state: reloaded

3. Running It from the Terminal
​I validated the syntax and ran a dry-run check before executing:

# 1. Syntax check
ansible-playbook -i inventory.ini playbook.yml --syntax-check

# 2. Dry run simulation
ansible-playbook -i inventory.ini playbook.yml --check

# 3. Live execution
ansible-playbook -i inventory.ini playbook.yml

Live Terminal Run Output:
PLAY [Automate Linux Node Hardening and Nginx Proxy] ***************************

TASK [Gathering Facts] *********************************************************
ok: [node01]

TASK [Update apt cache and upgrade system packages] ****************************
changed: [node01]

TASK [Install baseline security and proxy packages] ****************************
changed: [node01]

TASK [Configure UFW default policies] ******************************************
ok: [node01] => (item={'direction': 'incoming', 'policy': 'deny'})
ok: [node01] => (item={'direction': 'outgoing', 'policy': 'allow'})

TASK [Allow essential inbound ports] *******************************************
changed: [node01] => (item=22)
changed: [node01] => (item=80)
changed: [node01] => (item=443)

TASK [Enable UFW service] ******************************************************
changed: [node01]

TASK [Deploy Fail2ban configuration] *******************************************
changed: [node01]

TASK [Deploy Nginx reverse proxy configuration] ********************************
changed: [node01]

TASK [Ensure baseline services are running and enabled at boot] ****************
ok: [node01] => (item=ufw)
ok: [node01] => (item=fail2ban)
ok: [node01] => (item=nginx)

RUNNING HANDLER [Restart Fail2ban] *********************************************
changed: [node01]

RUNNING HANDLER [Reload Nginx] *************************************************
changed: [node01]

PLAY RECAP *********************************************************************
node01                     : ok=10   changed=7    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

4. Live Verification & Testing
​To confirm the automated configuration worked as expected, I performed three direct checks:
​Test 1: Idempotency Check
​I immediately re-ran the playbook against the same node:
ansible-playbook -i inventory.ini playbook.yml

Result: ok=8 changed=0 unreachable=0 failed=0
Ansible recognized that the target state already matched, making zero modifications and triggering zero service restarts.
​Test 2: Firewall Verification
​I verified that UFW active rules matched the configuration:

ssh ubuntu@192.168.1.50 "sudo ufw status verbose"

Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere

Test 3: Health Check & Headers
​I sent a test request to verify that the security headers and /healthz endpoint were live:

curl -i [http://192.168.1.50/healthz](http://192.168.1.50/healthz)

HTTP/1.1 200 OK
Server: nginx/1.18.0 (Ubuntu)
Content-Type: text/plain
Content-Length: 8
Connection: keep-alive
X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff

healthy
Enter fullscreen mode Exit fullscreen mode

Top comments (0)