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: Ansible turns up in a large share of DevOps and Platform Engineering job descriptions. 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 – Default-deny firewall for host services (plus a report of Docker-published ports, which UFW does not filter)
  • rsyslog – Local log files such as auth.log (add a remote target if you want 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 (sudo apt install ansible, or pipx install --include-deps ansible for the latest release; a bare pip install ansible fails on Ubuntu 24.04+ and Debian 12+ with an externally-managed-environment error). The ufw module comes from the community.general collection, which the full ansible package includes.
  • 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 it later to report which ports Docker is publishing.

The Security Baseline Playbook

Written for current Debian and Ubuntu targets (Debian 12/13, Ubuntu 24.04/26.04). 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 (host services, not Docker)
    ufw_allowed_ports:
      - "{{ ssh_port }}"

  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
    # ============================================
    # sshd uses the FIRST value it reads for each setting, and the Include of
    # /etc/ssh/sshd_config.d/*.conf sits at the top of sshd_config. Editing
    # sshd_config itself is overridden by drop-ins such as 50-cloud-init.conf
    # (PasswordAuthentication yes), so write our own drop-in that sorts first.

    - name: Configure SSH hardening drop-in
      copy:
        dest: /etc/ssh/sshd_config.d/00-baseline.conf
        content: |
          # Managed by Ansible (linux-baseline.yml)
          PermitRootLogin {{ ssh_permit_root_login }}
          PasswordAuthentication {{ ssh_password_authentication }}
          KbdInteractiveAuthentication no
        owner: root
        group: root
        mode: '0644'
      notify: Restart SSH

    - name: Validate sshd configuration
      command: sshd -t
      changed_when: false

    # ============================================
    # 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)
    # ============================================
    # No "state: reset": rules declared with the ufw module are idempotent,
    # so re-runs change nothing unless a rule is missing. SSH is allowed
    # before the firewall is enabled, so the play cannot lock itself out.

    - 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: Enable UFW
      ufw:
        state: enabled

    # ============================================
    # DOCKER: REPORT PUBLISHED PORTS (UFW CANNOT FILTER THESE)
    # ============================================

    - name: List Docker-published ports
      shell: "{% raw %}docker ps --format '{{.Names}}: {{.Ports}}'{% endraw %}"
      register: docker_ports
      when: is_docker_host | default(false)
      changed_when: false
      failed_when: false

    - name: Show what Docker publishes (bypasses UFW)
      debug:
        msg: "{{ docker_ports.stdout_lines }}"
      when: docker_ports.stdout_lines is defined

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

    - name: Check for an existing ClamAV signature database
      find:
        paths: /var/lib/clamav
        patterns:
          - main.cvd
          - main.cld
      register: clamav_db

    - name: Stop clamav-freshclam for the first download
      service:
        name: clamav-freshclam
        state: stopped
      when: clamav_db.matched == 0

    - name: Download the initial ClamAV database
      command: freshclam
      when: clamav_db.matched == 0

    - 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:
    # The unit is "ssh" on Debian and Ubuntu ("sshd" is only an alias)
    - name: Restart SSH
      service:
        name: ssh
        state: restarted

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

The playbook expects a fail2ban template next to it. Create playbooks/templates/jail.local.j2:

[DEFAULT]
bantime  = {{ fail2ban_bantime }}
maxretry = {{ fail2ban_maxretry }}

[sshd]
enabled = true
port    = {{ ssh_port }}

The Docker Catch: UFW Does Not Filter Published Ports

It is tempting to have the playbook discover which ports Docker publishes and add UFW rules for them. Don’t: those rules do nothing. Docker writes its own iptables rules and routes published-port traffic in the nat table before it reaches the chains UFW manages. Docker’s own documentation says Docker and ufw “are incompatible” for this reason. A container started with -p 8080:80 is reachable from the network even with UFW at default deny and no rule for 8080.

So the playbook only reports what Docker publishes, so you can see the exposure on each Docker host:

- name: List Docker-published ports
  shell: "{% raw %}docker ps --format '{{.Names}}: {{.Ports}}'{% endraw %}"
  register: docker_ports
  when: is_docker_host | default(false)
  changed_when: false
  failed_when: false

The {% raw %} wrapper matters. Ansible runs every string through Jinja2 first, so an unescaped {{.Ports}} is parsed as a Jinja expression and the task errors out before Docker ever sees it.

To actually restrict a container, control it in Docker: publish only to localhost (-p 127.0.0.1:8080:80, or "127.0.0.1:8080:80" in Compose) and put a reverse proxy in front, or add filtering rules to the DOCKER-USER chain, which Docker evaluates before its own rules.

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 stops the service, downloads the database, then restarts it, and only on a host that has no signature database yet; after that the service keeps itself updated.

Don’t reset UFW in a playbook: ufw: state: reset wipes every rule on every run, so the play is never idempotent and anything added by hand disappears. Declare the rules you want and let the module converge, and make sure SSH is allowed before UFW is enabled. Always test on a non-production box first.

Docker bypasses UFW: UFW rules for Docker-published ports have no effect, because Docker’s own iptables rules handle that traffic first. Restrict containers in Docker itself (bind to 127.0.0.1 or use the DOCKER-USER chain). The report task only runs on hosts flagged is_docker_host, and failed_when: false keeps it harmless where Docker isn’t installed.

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, and knowing where the tools stop (Docker bypassing UFW) matters as much as the tools
  • “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

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

Frequently Asked Questions

How do I automate Linux server hardening with Ansible?

Create an Ansible playbook that configures SSH hardening (disable root login, key-only auth), sets up a firewall (UFW or firewalld), enables automatic security updates, configures fail2ban for brute force protection, sets proper file permissions, and disables unnecessary services. Run the playbook against your inventory to apply the baseline consistently across all servers.

What should a Linux security baseline include?

A solid baseline covers SSH hardening (key-only authentication, non-standard port, disable root login), firewall configuration (deny all inbound, allow only required ports), automatic security updates, fail2ban or similar brute force protection, audit logging, disabling unnecessary services, and proper file permission enforcement.

Can Ansible detect which SSH port a server is using?

Yes. You can use Ansible to dynamically detect the SSH port by attempting connection on common ports or by parsing the sshd_config file. This is useful when hardening servers that may already have non-standard SSH ports configured, avoiding the need to manually track port assignments across your fleet.

Is Ansible idempotent for security hardening tasks?

Yes, when using Ansible modules correctly. Modules like lineinfile, ufw, service, and copy are idempotent by design, meaning running the playbook multiple times produces the same result without duplicating changes. This makes Ansible ideal for security baselines because you can re-run the playbook safely to enforce compliance.

Enjoyed this guide?

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

Scroll to Top