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, orpipx install --include-deps ansiblefor the latest release; a barepip install ansiblefails on Ubuntu 24.04+ and Debian 12+ with an externally-managed-environment error). Theufwmodule comes from thecommunity.generalcollection, which the fullansiblepackage 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
- 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
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.
Before you expose any of this: the seven steps that decide whether a home lab survives contact with the internet, in the order they matter, are in How to Secure a Homelab Network in 2026.
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.
RTFM Apparel · printed in the UK

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.







