Capstone — Automating ws1's Web Server Hardening

Ansible

Chapter 10 · Capstone — Automating ws1's Web Server Hardening

The final chapter. ws1's own web-server hardening course was built entirely by hand — typing commands over SSH, editing files directly. This capstone turns five of its eight chapters into one real, idempotent, version-controlled Ansible project — the exact "fix the config, not just the console" discipline ansible1-1 opened this whole course with, finally made concrete.

The Target — ws1's Own Hardening Course, Automated

Five of ws1's eight chapters, each becoming its own role, per ansible1-7's own DRY principle: HTTPS via Let's Encrypt, firewall via UFW, fail2ban, SSH hardening, and Apache security headers.

roles/ firewall/ ssh_hardening/ https/ fail2ban/ security_headers/
- name: Harden the web server hosts: webservers become: true roles: - firewall - ssh_hardening - https - fail2ban - security_headers

Role 1 — firewall (UFW)

- name: Allow SSH community.general.ufw: rule: allow port: "22" proto: tcp - name: Allow HTTPS community.general.ufw: rule: allow port: "443" proto: tcp - name: Enable UFW, deny by default community.general.ufw: state: enabled policy: deny

Role 2 — ssh_hardening

- name: Disable root login lineinfile: path: /etc/ssh/sshd_config regexp: '^#?PermitRootLogin' line: 'PermitRootLogin no' notify: restart sshd

lineinfileansible1-4's own example of a genuinely idempotent edit, checking the file's current content before deciding whether to change anything.

Role 3 — https (Let's Encrypt)

- name: Obtain certificate via certbot command: certbot --apache -d "{{ domain_name }}" --non-interactive --agree-tos -m "{{ admin_email }}" args: creates: "/etc/letsencrypt/live/{{ domain_name }}/fullchain.pem"

certbot is invoked via command, since no dedicated module covers this exact flow — but the creates argument is ansible1-4's own fix for command/shell's non-idempotency trap, applied for real: the task only actually runs if the certificate doesn't already exist.

Role 4 — fail2ban

- name: Deploy fail2ban jail config template: src: jail.local.j2 dest: /etc/fail2ban/jail.local notify: restart fail2ban

ansible1-5's own template module, doing exactly what it was introduced to do — rendering a real config file per host, with ansible1-6's own handler restarting the service only when that file genuinely changed.

Role 5 — security_headers (Apache)

# security_headers.conf.j2 {% for header in security_headers %} Header always set {{ header.name }} "{{ header.value }}" {% endfor %}
security_headers: - { name: "X-Frame-Options", value: "SAMEORIGIN" } - { name: "X-Content-Type-Options", value: "nosniff" }

ansible1-5's own {% for %} loop example, no longer a teaching snippet — this is the actual mechanism generating ws1's own Apache security-header configuration, driven by a plain, editable list of headers rather than typed by hand into a config file.

Secrets — Vault-Protecting admin_email and Anything Sensitive

admin_email: !vault | $ANSIBLE_VAULT;1.1;AES256 3662386163...

ansible1-8's own encrypt_string, applied to admin_email (and any API token a real deployment might also need) — the rest of the vars file stays fully readable, only the sensitive value itself is hidden.

Verifying Idempotency — Running the Whole Playbook Twice

# First run web1.example.com : ok=15 changed=9 unreachable=0 failed=0 # Second run, immediately after web1.example.com : ok=15 changed=0 unreachable=0 failed=0

ansible1-6's own practice, applied to the whole capstone: changed=0 on the second run is the actual proof this playbook is safe to re-run on a schedule, as a real, ongoing defense against configuration drift — not a claim taken on faith.

PieceChapter
Role structure, one per ws1 concernansible1-7
lineinfile idempotent editansible1-4
command + creates for certbotansible1-4
template + Jinja2 for-loopansible1-5
notify/handlers, restart-on-changeansible1-6
Vault-encrypted admin_emailansible1-8
Second-run idempotency verificationansible1-6
Provisioning/configuring division of laboransible1-1, tf1-1
Still out of scope, honestly
This capstone deliberately covers five of ws1's eight chapters — ClamAV and Monitoring & Maintenance are left out, to keep the capstone focused rather than exhaustive. No Molecule tests were written for these specific roles (ansible1-9 covered the technique; applying it here is a genuine next step, not done in this chapter). This capstone uses a plain, static inventory — a deliberate choice for a small, fixed set of known servers, not ansible1-9's own dynamic inventory, which solves a different problem. And every role here is Debian-specific, matching ws1's own scope exactly — none of it is portable to a RedHat-family host without real additional when: conditions.

Hands-On Exercises

Exercise 1

Write the fail2ban role's own task (using the template module, matching the chapter's own pattern) that deploys jail.local and notifies a handler named "restart fail2ban" only when the file's content actually changes.

📄 View solution
Exercise 2

Explain why the certbot task uses command with a creates argument instead of running unconditionally on every playbook run, referencing ansible1-4's own idempotency material directly.

📄 View solution
Exercise 3

A colleague asks why admin_email needs Vault protection when it's "just an email address, not really a secret." Give an honest answer, and name one genuinely sensitive value this capstone's own roles would need to protect the same way in a real deployment.

📄 View solution

Chapter 10 Quick Reference — Ansible Complete

  • Five ws1 chapters (HTTPS, UFW, fail2ban, SSH hardening, Apache security headers) become five roles, one playbook
  • lineinfile and command+creates deliver ansible1-4's own idempotent-edit and idempotent-escape-hatch patterns for real
  • template + Jinja2 {% for %} generates real Apache config from a plain list of headers, not hand-typed text
  • notify/handlers restart services only when something genuinely changed — ansible1-6's own principle, applied
  • Vault protects admin_email and any real secrets, staying fully readable elsewhere in the vars file
  • changed=0 on a second run is the actual, checkable proof this playbook is safe to re-run on a schedule
  • Honest scope note: ClamAV/Monitoring left out, no Molecule tests written here, static (not dynamic) inventory, Debian-only
  • The full Ansible course — 10 chapters — is now complete.