The Ansible Playbook I Run on Every New Server
Advertisement
I've written before about the checklist I run through on every new server: a non-root user, key-only SSH, a default-deny firewall, Fail2ban, and unattended upgrades. Doing it by hand takes under an hour. It's not hard.
But "under an hour, by hand, for every server" adds up fast once you manage more than three servers. Manual steps are exactly where small inconsistencies creep in. One server gets MaxAuthTries 3 and another doesn't, purely because the person setting it up was in a rush that day.
The fix is turning the checklist into a playbook. Same steps, same order, every time. And it's idempotent: running it again on an already-configured server changes nothing.
The playbook
---
- name: Baseline hardening for a new server
hosts: new_servers
become: true
vars:
admin_user: deploy
ssh_public_key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
tasks:
- name: Create admin user
user:
name: "{{ admin_user }}"
groups: sudo
shell: /bin/bash
create_home: true
- name: Add SSH key for admin user
authorized_key:
user: "{{ admin_user }}"
key: "{{ ssh_public_key }}"
- name: Harden SSH config (overrides cloud-init defaults)
copy:
dest: /etc/ssh/sshd_config.d/99-hardening.conf
content: |
PermitRootLogin no
PasswordAuthentication no
MaxAuthTries 3
mode: '0644'
notify: restart sshd
- name: Install UFW and Fail2ban
apt:
name: [ufw, fail2ban]
state: present
update_cache: true
- name: Configure UFW defaults
ufw:
direction: "{{ item.direction }}"
policy: "{{ item.policy }}"
loop:
- { direction: incoming, policy: deny }
- { direction: outgoing, policy: allow }
- name: Allow required ports
ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop: ['22', '80', '443']
- name: Enable UFW
ufw:
state: enabled
- name: Enable Fail2ban
systemd:
name: fail2ban
enabled: true
state: started
- name: Install unattended-upgrades
apt:
name: unattended-upgrades
state: present
handlers:
- name: restart sshd
systemd:
name: ssh
state: restarted
Running it
When a fresh server comes online, it gets added to the inventory and the playbook runs against just that host:
ansible-playbook -i inventory.ini baseline.yml -l new-server-01 -u root
The first run must connect as root—it's the only user available on a fresh box. Once the playbook completes, root login is disabled. From then on, deploy is your entry point:
ansible-playbook -i inventory.ini baseline.yml -l new-server-01 -u deploy
The first run does all the heavy lifting: creates the user, locks down SSH, and sets up the firewall. The handler only restarts sshd if the config actually changed. Every run after that is just a no-op confirmation that the configuration hasn't drifted.
Try it interactively: The Ansible Baseline Generator on this site lets you configure this playbook through a UI. You can set the admin username, SSH port, choose modules, and copy the exact YAML output.
Why idempotency is the actual point
The real value here isn't the time saved on day one. Typing commands manually isn't that slow. The value is that six months later, when I'm not sure if a server was hardened properly or rushed during an incident, I can just run the playbook again.
If everything is in place, Ansible reports zero changes, and I move on. If something is missing, it gets fixed on the spot. I don't need to remember which of the five manual steps was skipped.
This playbook is intentionally small. It doesn't install application stacks or configure project-specific services. It is the absolute floor every server stands on before anything else goes on top. Keeping it separate from app-specific playbooks ensures it stays stable. A baseline that doesn't change often is one you can trust blindly.
Advertisement