What We’re Building
Every new server you provision starts in the same insecure state: root login enabled, password authentication open, no firewall, no dedicated deploy user. Doing this by hand is error-prone and doesn’t scale past two or three machines. The goal here is a single Ansible playbook — bootstrap.yml — that you run once against a fresh host and walk away with a properly hardened baseline.
The playbook covers five concrete outcomes: system packages updated and a curated set installed, a non-root deployer user created with sudo access, your SSH public key injected for that user, root and password-based SSH login disabled, and UFW configured with a default-deny policy that allows only SSH, HTTP, and HTTPS. Every step is idempotent, so running it again on an already-bootstrapped host is safe.
Prerequisites
Before touching the playbook, confirm the following on your control node and target hosts.
Control node requirements:
- Ansible 2.12 or later —
ansible --versionto verify - An SSH key pair at
~/.ssh/id_rsaand~/.ssh/id_rsa.pub. The playbook reads the public key from that path vialookup('file', ...). If your key lives elsewhere, update theauthorized_keytask accordingly. - Python 3 available on the control node (Ansible’s own dependency)
Target host requirements:
- Ubuntu 20.04 or 22.04 (the playbook uses
apt— Debian-family only) - Root or a user with passwordless sudo accessible via SSH from the control node for the initial run
- Python 3 installed on the target — most cloud images include it; if not, run
apt install python3manually before proceeding
Inventory file: Create inventory.ini in your project directory. Ansible also reads from /etc/ansible/hosts, but keeping a project-local file is cleaner for version control.
[servers]
192.0.2.10 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
[servers:vars]
ansible_python_interpreter=/usr/bin/python3
Verify connectivity before running anything else. A failed ping here means a network, key, or inventory problem — not an Ansible bug.
ansible all -i inventory.ini -m ping
You should see pong back from every host in the group. If you don’t, stop and resolve connectivity first.
One optional but useful setting for local development: create an ansible.cfg in the project root to suppress host key warnings on first contact with new machines.
[defaults]
host_key_checking = False
Re-enable this in any environment where hosts are long-lived or shared. Disabling it in production removes a meaningful MITM protection.
Writing the Bootstrap Playbook
The playbook is structured as a single play targeting all hosts in the inventory. become: true is set at the play level because every task here touches system files or the package manager — there’s no point toggling it per task. The vars block at the top is where you tune the deploy username, SSH port, and package list without touching task logic.
The handlers section deserves attention before we look at the tasks. Handlers in Ansible run at the end of a play, and only if notified. This means sshd restarts once after all SSH config changes are applied — not once per lineinfile task. A common mistake is omitting handlers entirely and wondering why sshd_config changes don’t take effect until the next manual reboot.
Here is the complete playbook. We’ll break down the security-specific tasks in the next section.
---
# bootstrap.yml — Ansible playbook for initial server hardening
# Usage: ansible-playbook -i inventory.ini bootstrap.yml --ask-become-pass
- name: Bootstrap fresh server
hosts: all
become: true
vars:
deploy_user: deployer
ssh_port: 22
packages:
- curl
- git
- ufw
- fail2ban
- unattended-upgrades
handlers:
- name: Restart sshd
service:
name: sshd
state: restarted
- name: Reload ufw
command: ufw reload
tasks:
- name: Update apt cache and upgrade packages
apt:
update_cache: yes
upgrade: dist
cache_valid_time: 3600
- name: Install required packages
apt:
name: "{{ packages }}"
state: present
- name: Create deploy user
user:
name: "{{ deploy_user }}"
shell: /bin/bash
groups: sudo
append: yes
create_home: yes
- name: Add SSH public key for deploy user
authorized_key:
user: "{{ deploy_user }}"
state: present
key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"
- name: Disable root SSH login
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PermitRootLogin'
line: 'PermitRootLogin no'
state: present
notify: Restart sshd
- name: Disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
notify: Restart sshd
- name: Allow SSH through UFW
ufw:
rule: allow
port: "{{ ssh_port }}"
proto: tcp
- name: Allow HTTP and HTTPS
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "80"
- "443"
- name: Enable UFW with default deny
ufw:
state: enabled
default: deny
notify: Reload ufw
- name: Enable fail2ban service
service:
name: fail2ban
enabled: yes
state: started
A few implementation notes worth calling out:
The apt task sets cache_valid_time: 3600. This tells Ansible to skip refreshing the package cache if it was updated within the last hour, which avoids redundant network calls on repeated runs. Without update_cache: yes, you risk installing packages from a stale index — a common source of version drift between environments.
The user task uses append: yes alongside groups: sudo. Without append, Ansible replaces the user’s group memberships entirely. On a host where the user already exists and belongs to other groups, omitting append silently removes those group assignments.
Hardening SSH and Configuring UFW
The two lineinfile tasks that modify /etc/ssh/sshd_config use regexp to match existing directives regardless of whether they’re commented out or set to a different value. This is intentional. A fresh Ubuntu image ships with PermitRootLogin prohibit-password — a template approach that replaces the entire config file would work, but it also discards any distribution-specific defaults you might want to preserve. Surgical lineinfile edits are safer when you only need to change one or two values.
The UFW task order is not negotiable. You must allow SSH before enabling the firewall. The playbook does this correctly — the allow rules for port 22, 80, and 443 all run before the state: enabled task. If you reorder these tasks and run the playbook against a remote host, you will lock yourself out the moment UFW activates with its default-deny policy.
The fail2ban package is installed and started but not deeply configured here. Out of the box, fail2ban reads /etc/fail2ban/jail.conf and activates the SSH jail automatically on Debian-family systems. That default behavior — banning IPs after five failed SSH attempts within ten minutes — is meaningful protection even without custom configuration. Extending it is covered in the next section.
The unattended-upgrades package is included in the install list. It doesn’t activate automatically on all Ubuntu versions just by being installed. To enable it properly, you’d add a task using the debconf module or drop a config file into /etc/apt/apt.conf.d/. That’s intentionally left out of this playbook to keep scope focused — but don’t forget it exists in your package list without activation logic.
Running the Playbook and Verifying Results
Run the playbook with the --ask-become-pass flag if your initial connection user requires a sudo password. On most fresh cloud instances where you connect as root directly, you can omit it.
ansible-playbook -i inventory.ini bootstrap.yml --ask-become-pass
Watch the output for any FAILED or UNREACHABLE lines. Ansible prints a recap at the end — a clean run looks like this:
PLAY RECAP *********************************************************************
192.0.2.10 : ok=12 changed=8 unreachable=0 failed=0 skipped=0
After the run completes, verify each outcome manually. SSH in as the deploy user using key authentication:
ssh -i ~/.ssh/id_rsa [email protected]
Confirm root login is blocked:
ssh [email protected]
# Expected: Permission denied (publickey)
Check UFW status on the target host:
sudo ufw status verbose
Expected output shows default incoming policy as deny with explicit allow rules for ports 22, 80, and 443. Confirm fail2ban is running:
sudo systemctl status fail2ban
Run the playbook a second time to verify idempotency. Every task should report ok rather than changed. If any task reports changed on a second run, that’s a signal the task isn’t truly idempotent and deserves a closer look — usually a missing state: present or an imprecise regexp.
What to Do Next
This playbook is a solid baseline, but it’s structured as a flat task list. Once you’re running it against more than a handful of servers or across different environments, converting it to an Ansible role is the right move. Roles enforce a directory structure that makes the playbook composable — you can apply a common role, a hardening role, and an application-specific role independently or together.
For secrets — particularly if you’re distributing SSH keys or storing sudo passwords — integrate Ansible Vault. Encrypt sensitive vars with ansible-vault encrypt_string and reference them in your playbook the same way you’d reference any variable. The encrypted value is safe to commit to version control.
If you’re provisioning servers through Terraform, the natural integration point is a null_resource with a local-exec provisioner that calls ansible-playbook after the instance is created. This keeps infrastructure provisioning and configuration management in the same pipeline without coupling them tightly.
Finally, consider extending the fail2ban configuration. The default SSH jail is useful, but adding jails for Nginx or Apache access logs — banning IPs that trigger repeated 4xx errors — meaningfully reduces noise in your logs and load on your application layer. That configuration belongs in a dedicated fail2ban role rather than this bootstrap playbook.
