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 ansibleorpip 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
- Ansible Documentation
- CIS Benchmarks – Industry-standard security baselines
- Ansible Examples Repository
- fail2ban Documentation
Next Steps
- Test on one server first – Never run untested automation across your entire fleet
- Customise the variables – Adjust fail2ban thresholds, scan schedules, and allowed ports for your environment
- Add to version control – Your infrastructure config deserves the same rigour as application code
- 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.
This guide is part of the Automation Fundamentals series. See the full series for more guides like this.

ReadTheManual is run, written and curated by Eric Lonsdale.
Eric has over 20 years of professional experience in IT infrastructure, cloud architecture, and cybersecurity, but started with PCs long before that.
He built his first machine from parts bought off tables at the local college campus, hoping they worked. He learned on BBC Micros and Atari units in the early 90s, and has built almost every PC he’s used between 1995 and now.
From helpdesk to infrastructure architect, Eric has worked across enterprise datacentres, Azure environments, and security operations. He’s managed teams, trained engineers, and spent two decades solving the problems this site teaches you to solve.
ReadTheManual exists because Eric believes the best way to learn IT is to build things, break things, and actually read the manual. Every guide on this site runs on infrastructure he owns and maintains.
Enjoyed this guide?
New articles on Linux, homelab, cloud, and automation every 2 days. No spam, unsubscribe anytime.


