Ansible Playbook for Automated Linux Server Bootstrap and SSH Hardening

devops-automation

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 --version to verify
  • An SSH key pair at ~/.ssh/id_rsa and ~/.ssh/id_rsa.pub. The playbook reads the public key from that path via lookup('file', ...). If your key lives elsewhere, update the authorized_key task 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 python3 manually 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.

official docs

Leave a Reply

Your email address will not be published. Required fields are marked *

Support us · 💳 Monobank