How to Secure Linux Servers with Ansible – Security Baseline Playbook

How to Secure Linux Servers with Ansible – Security Baseline Playbook

How I Secured My Entire Linux Fleet in One Day with Ansible

The hook: I standardised security across 13 Linux servers in one day. One playbook. One command. Here’s how, using the same Infrastructure as Code approach you’d use in enterprise environments.

The Problem: Manual Hardening Doesn’t Scale

I’d been “hardening” servers manually for years. SSH in, run some commands, move on. Every host slightly different. No consistency. No proof it was actually done.

Sound familiar?

The uncomfortable truth: most homelabs have security configurations that are tribal knowledge at best. “I think I set up fail2ban on that box” isn’t a security posture – it’s hope.

In enterprise environments, this is solved with configuration management. Ansible, Puppet, Chef, SaltStack – tools that define desired state and enforce it across your fleet. The same approach works for homelabs, and learning it is worth serious money.

Career context: Infrastructure as Code skills command premium salaries. DevOps and Platform Engineering roles requiring Ansible experience regularly pay £60-80k+. This isn’t just about securing your homelab – it’s about building demonstrable enterprise skills.

What We’re Building

A reusable Ansible playbook that deploys a complete security baseline to any Debian/Ubuntu server:

  • ClamAV – Antivirus with scheduled scans
  • fail2ban – Brute force protection
  • UFW – Firewall with dynamic port discovery
  • rsyslog – Centralised logging
  • auditd – Security auditing
  • SSH hardening – No root login, key-only authentication
  • Automatic security updates – Unattended upgrades

Run it once, run it on 100 servers – same result every time. That’s the power of declarative configuration.

Prerequisites

Before starting, you’ll need:

  • Ansible installed on your control machine (apt install ansible or pip install ansible)
  • SSH key-based access to your target servers
  • Sudo privileges on target hosts
  • An inventory file listing your servers

If you’re new to Ansible, the official getting started guide covers the basics in about 30 minutes.

The Inventory File

First, define your hosts. Create inventory/hosts.yml:

all:
  children:
    linux_servers:
      hosts:
        server1:
          ansible_host: 192.168.1.10
        server2:
          ansible_host: 192.168.1.11
        docker_host:
          ansible_host: 192.168.1.20
          is_docker_host: true
      vars:
        ansible_user: admin
        ansible_become: true

Note the is_docker_host variable – we’ll use this for dynamic port discovery later.

The Security Baseline Playbook

Create playbooks/linux-baseline.yml:

---
- name: Apply Linux Security Baseline
  hosts: linux_servers
  become: true

  vars:
    # SSH Configuration
    ssh_permit_root_login: "no"
    ssh_password_authentication: "no"
    ssh_port: 22

    # Fail2ban Configuration
    fail2ban_maxretry: 5
    fail2ban_bantime: 3600

    # ClamAV scan schedule (daily at 2 AM)
    clamav_scan_hour: 2
    clamav_scan_minute: 0

    # Firewall - always allowed ports
    ufw_allowed_ports:
      - 22    # SSH

  tasks:
    # ============================================
    # PACKAGE INSTALLATION
    # ============================================

    - name: Update apt cache
      apt:
        update_cache: yes
        cache_valid_time: 3600

    - name: Install security packages
      apt:
        name:
          - ufw
          - fail2ban
          - clamav
          - clamav-daemon
          - auditd
          - rsyslog
          - unattended-upgrades
        state: present

    # ============================================
    # SSH HARDENING
    # ============================================

    - name: Configure SSH - disable root login
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin {{ ssh_permit_root_login }}'
      notify: Restart SSH

    - name: Configure SSH - disable password auth
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PasswordAuthentication'
        line: 'PasswordAuthentication {{ ssh_password_authentication }}'
      notify: Restart SSH

    # ============================================
    # FAIL2BAN
    # ============================================

    - name: Configure fail2ban jail.local
      template:
        src: templates/jail.local.j2
        dest: /etc/fail2ban/jail.local
        mode: '0644'
      notify: Restart fail2ban

    - name: Enable and start fail2ban
      service:
        name: fail2ban
        state: started
        enabled: yes

    # ============================================
    # FIREWALL (UFW) WITH DYNAMIC PORT DISCOVERY
    # ============================================

    - name: Get Docker exposed ports
      shell: docker ps --format "{{.Ports}}" | grep -oP '\d+(?=->)' | sort -u
      register: docker_ports
      when: is_docker_host | default(false)
      changed_when: false
      failed_when: false

    - name: Reset UFW to defaults
      ufw:
        state: reset

    - name: Set UFW default deny incoming
      ufw:
        direction: incoming
        policy: deny

    - name: Set UFW default allow outgoing
      ufw:
        direction: outgoing
        policy: allow

    - name: Allow standard ports
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: "{{ ufw_allowed_ports }}"

    - name: Allow Docker exposed ports
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: "{{ docker_ports.stdout_lines | default([]) }}"
      when: is_docker_host | default(false) and docker_ports.stdout_lines is defined

    - name: Enable UFW
      ufw:
        state: enabled

    # ============================================
    # CLAMAV
    # ============================================

    - name: Stop clamav-freshclam for update
      service:
        name: clamav-freshclam
        state: stopped
      ignore_errors: yes

    - name: Update ClamAV database
      command: freshclam
      ignore_errors: yes

    - name: Start clamav-freshclam
      service:
        name: clamav-freshclam
        state: started
        enabled: yes

    - name: Create ClamAV scan cron job
      cron:
        name: "Daily ClamAV scan"
        hour: "{{ clamav_scan_hour }}"
        minute: "{{ clamav_scan_minute }}"
        job: "/usr/bin/clamscan -r /home --log=/var/log/clamav/daily-scan.log"

    # ============================================
    # AUDITD
    # ============================================

    - name: Enable and start auditd
      service:
        name: auditd
        state: started
        enabled: yes

    # ============================================
    # AUTOMATIC SECURITY UPDATES
    # ============================================

    - name: Configure unattended-upgrades
      copy:
        dest: /etc/apt/apt.conf.d/20auto-upgrades
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";
          APT::Periodic::AutocleanInterval "7";
        mode: '0644'

  handlers:
    - name: Restart SSH
      service:
        name: sshd
        state: restarted

    - name: Restart fail2ban
      service:
        name: fail2ban
        state: restarted

The Clever Bit: Dynamic Port Discovery

The hardest part of firewall configuration is knowing what ports to open. Hardcode them and you’ll forget to update when services change. Leave them open and you’ve defeated the purpose.

This playbook solves it by querying Docker at runtime:

- name: Get Docker exposed ports
  shell: docker ps --format "{{.Ports}}" | grep -oP '\d+(?=->)' | sort -u
  register: docker_ports
  when: is_docker_host | default(false)

For Docker hosts, it discovers which ports are actually exposed and adds UFW rules dynamically. No hardcoding, no manual updates, no forgotten services.

This pattern – querying actual state rather than maintaining static lists – is how enterprise configuration management works at scale.

Running the Playbook

# Dry run first - see what would change
ansible-playbook -i inventory/hosts.yml playbooks/linux-baseline.yml --check

# Run for real
ansible-playbook -i inventory/hosts.yml playbooks/linux-baseline.yml

# Run on specific hosts only
ansible-playbook -i inventory/hosts.yml playbooks/linux-baseline.yml --limit server1,server2

First run on a fresh server takes about 3-4 minutes. Subsequent runs complete in under a minute, only applying changes where needed.

Verifying the Results

After the playbook runs, verify your security baseline:

# Check UFW status
sudo ufw status verbose

# Verify fail2ban is running
sudo fail2ban-client status

# Check SSH configuration
sudo sshd -T | grep -E "permitrootlogin|passwordauthentication"

# Verify auditd
sudo systemctl status auditd

# Check ClamAV
sudo systemctl status clamav-freshclam

Lessons Learned

What Went Wrong

ClamAV freshclam conflicts: The freshclam service locks the database during updates. If you try to update manually while the service is running, it fails. The playbook now stops the service, updates, then restarts.

UFW reset timing: Resetting UFW before setting rules can briefly lock you out if you’re connected via SSH. The playbook handles this by ensuring port 22 is in the allowed list, but always test on a non-production box first.

Docker port discovery on non-Docker hosts: The shell command fails if Docker isn’t installed. The when conditional and failed_when: false handle this gracefully.

What I’d Do Differently

For a production environment, I’d add:

  • Ansible Vault for any secrets (not needed in this basic playbook)
  • Role-based structure instead of a single playbook file
  • Integration with a SIEM for log forwarding
  • Compliance reporting output

Career Application

This playbook demonstrates several enterprise-relevant skills:

Interview Talking Points

  • “Tell me about your experience with configuration management” – You’ve built a reusable security baseline using Ansible that can scale from 1 to 100+ servers
  • “How do you handle security at scale?” – Declarative configuration ensures consistency; dynamic discovery handles changing infrastructure
  • “What’s your approach to Infrastructure as Code?” – Version-controlled playbooks, idempotent operations, handler-based service management

Portfolio Ideas

  • Extend this into a full Ansible role with molecule testing
  • Add CI/CD pipeline that runs the playbook on infrastructure changes
  • Create a compliance dashboard showing baseline status across your fleet

Related Certifications

This hands-on experience directly supports:

  • Red Hat Certified Engineer (RHCE) – includes Ansible automation
  • AWS/Azure DevOps certifications – configuration management concepts
  • CompTIA Linux+ – system hardening and security

Connecting to Your 2026 Homelab

If you’re building a homelab in 2026, security automation isn’t optional – it’s essential. With hardware costs rising and refurbished mini PCs becoming the smart choice, protecting your investment matters.

This playbook works on any Debian/Ubuntu system – whether it’s a Raspberry Pi 5, a mini PC running Proxmox VMs, or a cloud VPS extending your lab.

Resources

Next Steps

  1. Test on one server first – Never run untested automation across your entire fleet
  2. Customise the variables – Adjust fail2ban thresholds, scan schedules, and allowed ports for your environment
  3. Add to version control – Your infrastructure config deserves the same rigour as application code
  4. Set up monitoring – Security tools are useless if you don’t watch the alerts (Wazuh guide coming soon)

The difference between “I think my servers are secure” and “I can prove my servers match the baseline” is one Ansible playbook. Build it once, run it everywhere.

The RTM Essential Stack - Gear I Actually Use

Enjoyed this guide?

New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.

Scroll to Top