[ OK ]Initializing kernel...
~/im/blog
Hire Me

Let's Talk

Got an infrastructure problem or need an extra hand? I'm open to discussing new projects.

Get in touch

Connect

Find me on social media and professional networks.

© 2026 Irfan Miral. All rights reserved.Developed byIrfan Miral
Privacy PolicyTerms & Conditions
HomeServicesAbout/ResumeBlogContactTools
2026-02-05• 5 min read

The Ansible Playbook I Run on Every New Server

DevOps Ansible Automation Linux Administration

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

Need help with this?

If you'd rather have someone handle Containerization & Automation for you, that's exactly what I do.

Get in Touch
PreviousA GitLab CI/CD Pipeline That Covers Most Small ProjectsNext Why Server Configs Belong in Git, Not in Your Head