Automate Linux Server Hardening

Use Ansible to automate security hardening of a Linux server on AWS.

Introduction

30 Second Summary

Every time a company gets hacked, someone forgot to lock a door. Securing a server means running dozens of commands in the right order, every single time, without mistakes.

In this project, you will build an automation pipeline that hardens a Linux server using Ansible on AWS. You'll write playbooks that lock down SSH access, configure firewalls, and set up intrusion detection, all triggered by a single command.

What You'll Build

A complete Ansible automation pipeline that launches two EC2 instances, configures one as a control node, and applies security hardening to the other through automated playbooks.

By the end of this project, you'll have:

  • Two configured EC2 instances on AWS (a control node running Ansible and a managed node receiving configuration).
  • A working Ansible playbook that automates base server setup including user creation, package installation, and SSH key management.
  • A security hardening playbook that locks down SSH, configures a firewall, enables intrusion detection, and turns on automatic security updates.
  • Secret Mission: Refactor your hardening playbook into a reusable Ansible role with variables, templates, and handlers.

Are there any prerequisites?

You'll need basic familiarity with the Linux command line (navigating directories, running commands). No prior Ansible or AWS experience is required. This project walks you through creating a new AWS account from scratch.

Launch EC2 Instances and Install Ansible

Ansible automates server configuration over SSH. It works by connecting from a control node (where you run commands) to managed nodes (the servers it configures). You need both to build an automation pipeline.

In this step, you'll create an AWS account, launch two EC2 instances on Ubuntu, and install Ansible on the control node so it can manage the other server remotely.

In this step, get ready to:
  • Create a new AWS account with free tier credits.
  • Launch two EC2 instances (control node and managed node).
  • Install Ansible on the control node and verify it works.
Create a new AWS account

AWS offers new accounts a free plan with $100 in sign-up credits. This covers the two small instances you'll run for this project.

  • Open a new browser tab and go to https://aws.amazon.com.
  • Click Create an AWS Account in the top right corner.
  • Enter your email address and choose an account name.
  • Complete the verification steps (email code, password, contact info, payment method).
  • When prompted to choose a Support plan, select Basic support - Free.
  • Click Complete sign up.
  • Wait for the activation email (usually a few minutes), then sign in to the AWS Management Console.

Why do I need a payment method for a free account?

AWS requires a card on file for identity verification. Your free plan includes $100 in credits, and the two t3.micro instances you'll launch cost roughly $0.02/hour total. You won't be charged unless you exceed your credits.

Launch two EC2 instances

You'll launch two instances in the same region. One is your control node (where Ansible runs). The other is your managed node (the server Ansible will configure).

  • In the AWS Console search bar at the top, type EC2 and click the EC2 service.
  • Confirm your region in the top-right corner of the console. Pick any region close to you (for example, us-east-1).
  • Click the orange Launch instance button.

You'll see a single-page launch wizard. Configure it as follows for the first instance:

  • Under Name and tags, enter ansible-control as the name.
  • Under Application and OS Images, click Quick Start, then select Ubuntu.
  • From the AMI dropdown, choose Ubuntu Server 24.04 LTS (marked Free Tier eligible).
  • Under Instance type, select t3.micro.
  • Under Key pair (login), click Create new key pair.
  • Enter managed-node-key as the key pair name.
  • Select RSA for the key pair type.
  • Select .pem for the private key file format.
  • Click Create key pair. The file managed-node-key.pem downloads automatically. Save it somewhere you can find it (your Downloads folder is fine for now).
  • Under Network settings, confirm that the security group allows SSH (port 22) inbound. The default wizard setting enables this.
  • Leave all other settings at their defaults.
  • Click Launch instance.

Now launch the second instance with the same settings:

  • Click Launch instance again from the EC2 dashboard.
  • Under Name and tags, enter ansible-managed.
  • Under Application and OS Images, select Ubuntu Server 24.04 LTS again.
  • Under Instance type, select t3.micro.
  • Under Key pair (login), select the existing managed-node-key key pair from the dropdown (do not create a new one).
  • Leave all other settings at defaults and click Launch instance.
  • Navigate to Instances in the left sidebar.
  • Wait for both instances to show Running in the Instance state column and 2/2 checks passed in the status checks column. This takes 1-2 minutes.

Why two instances?

Ansible's architecture requires a control node (where playbooks execute) and one or more managed nodes (the targets). The control node connects to managed nodes over SSH. Having both in the same AWS region means they share a private network, so Ansible can reach the managed node using its private IP.

Instance stuck in Pending?

If an instance stays in Pending for more than 5 minutes, try refreshing the page. EC2 instances occasionally take longer in busy regions. If it moves to Terminated instead of Running, your account may not be fully activated yet. Wait for the AWS activation email and try again.

Help me troubleshoot my EC2 instance launch

Connect to the control node and install Ansible

Windows has a built-in SSH client in PowerShell. You'll use it to connect to your control node, copy the private key over, and install Ansible.

First, grab the control node's public IP address:

  • In the EC2 Instances list, click on ansible-control.
  • In the instance details panel, find Public IPv4 address and copy it.

Now open PowerShell and copy your private key to the control node:

  • Press the Windows key on your keyboard to open the search bar.
  • Type PowerShell and press Enter to open it.
  • Copy the private key file to the control node by running this command:
scp -i [[PEMPATH="C:\Users\YourName\Downloads\managed-node-key.pem"]] [[PEMPATH="C:\Users\YourName\Downloads\managed-node-key.pem"]] ubuntu@[[EC2_PUBLIC_IP="your ansible-control public IPv4 address"]]:~/.ssh/managed-node-key.pem

What does this command do?

The scp command securely copies files over SSH. The -i flag specifies which key to authenticate with. You're using the same key file both to authenticate and as the file being copied to the remote server's ~/.ssh/ directory.

  • If prompted "Are you sure you want to continue connecting?", type yes and press Enter.
  • Now SSH into the control node by running:
ssh -i [[PEMPATH="C:\Users\YourName\Downloads\managed-node-key.pem"]] ubuntu@[[EC2_PUBLIC_IP="your ansible-control public IPv4 address"]]

You should see a welcome message from Ubuntu and a command prompt ending in $. You're now on the control node.

Seeing Permission denied or Unprotected private key file?

If you see a permissions error, your key file may have overly open permissions. In PowerShell, right-click the .pem file in File Explorer, go to Properties > Security > Advanced, disable inheritance, remove all users except your own account, and ensure only your account has Read access.

Also confirm you're using ubuntu as the username (not root or ec2-user). Ubuntu AMIs use ubuntu as the default SSH user.

Help me fix SSH permission errors connecting to my EC2 instance

Now set the correct permissions on the key and generate the public key:

  • Set the private key permissions to owner-read-only by running:
chmod 600 ~/.ssh/managed-node-key.pem
  • Generate the public key from the private key by running:
ssh-keygen -y -f ~/.ssh/managed-node-key.pem > ~/.ssh/managed-node-key.pub

Why generate a public key?

Later in this project, your Ansible playbook will copy this public key to a new user on the managed node. The ssh-keygen -y command extracts the public key from an existing private key file without creating a new key pair.

Now install Ansible. The official Ansible documentation recommends using the Ansible PPA for Ubuntu:

  • Add the Ansible PPA and install Ansible by running these commands:
# Add the official Ansible package repository
sudo apt update
sudo apt install software-properties-common -y
sudo add-apt-repository --yes --update ppa:ansible/ansible

# Install Ansible
sudo apt install ansible -y

What does this do?

The software-properties-common package provides the add-apt-repository command. Adding the Ansible PPA (Personal Package Archive) gives you a more recent version of Ansible than the default Ubuntu repository.

The sudo apt install ansible -y command installs Ansible along with all its dependencies, including the community.general collection you'll need later for firewall management.

  • As a safety step, ensure the community.general collection is available by running:
ansible-galaxy collection install community.general
  • Verify Ansible is installed by running:
ansible --version

You should see output showing the ansible-core version and its configuration details. The exact version number will depend on when you install it.

Command not found after install?

If ansible --version returns "command not found", try closing and reopening your SSH session. The install may have updated your PATH but the current shell hasn't picked it up yet.

If the add-apt-repository command failed, make sure software-properties-common installed successfully in the previous step. Re-run sudo apt install software-properties-common -y and try again.

Help me troubleshoot my Ansible installation on Ubuntu

Your control node is ready with Ansible installed and your key files in place. Next up, you'll configure Ansible's inventory so it knows where to find your managed node.

Configure Ansible Inventory and Test Connection

Your control node is live and Ansible is installed. Now Ansible needs to know which servers it manages and how to connect to them.

Ansible uses two files to answer these questions: a configuration file that sets global defaults, and an inventory file that lists your managed hosts. In this step, you will create both files and confirm that Ansible can talk to your managed node over SSH.

In this step, get ready to:
  • Create a project directory and an Ansible configuration file.
  • Write an inventory file with your managed node's private IP address.
  • Test the connection with Ansible's ping module.
Create the project directory and configuration file

Ansible looks for a configuration file in the current working directory first. By creating a dedicated project folder, you keep all your automation files organized and your config isolated from the system defaults.

  • Create a project directory and move into it by running these commands:
mkdir ~/ansible-project
cd ~/ansible-project
  • Confirm the directory was created by running:
pwd

You should see /home/ubuntu/ansible-project in the output.

  • Create the Ansible configuration file by running this command:
cat << 'EOF' > ansible.cfg
[defaults]
inventory = inventory.ini
remote_user = ubuntu
private_key_file = ~/.ssh/managed-node-key.pem
host_key_checking = False
EOF

What does this configuration do?

  • inventory = inventory.ini tells Ansible which file lists your managed hosts.
  • remote_user = ubuntu sets the SSH username for connecting to managed nodes. Ubuntu EC2 instances use ubuntu as the default user.
  • private_key_file points to the same key pair your EC2 instances were launched with.
  • host_key_checking = False skips the SSH fingerprint prompt so Ansible can connect without manual confirmation.
  • Verify the file was created correctly by running:
cat ansible.cfg

You should see the four settings printed back under the [defaults] header.

File looks empty or missing?

Make sure you ran the command from inside ~/ansible-project. Run pwd to confirm you are in /home/ubuntu/ansible-project.

If you accidentally ran the cat << 'EOF' command without the closing EOF on its own line, the shell is still waiting for input. Press Ctrl+C to cancel, then try again.

Help me troubleshoot my ansible.cfg file creation

Create the inventory file

An Ansible inventory lists the servers you want to manage. Each entry maps a friendly name to a real IP address so your playbooks can target groups of servers by name instead of memorizing IPs.

Finding your managed node's private IP

Switch to the AWS EC2 console in your browser. Select the ansible-managed instance and look for the Private IPv4 address field in the details panel. Copy that value.

  • Create the inventory file by running this command, replacing the IP placeholder with your managed node's private IP:
cat << EOF > inventory.ini
[managed]
server1 ansible_host=[[MANAGED_IP="your managed node's private IP (e.g. 172.31.20.45)"]]
EOF

What does this inventory do?

  • [managed] defines a group name. Your playbooks will target this group to run tasks on all servers inside it.
  • server1 is a friendly alias for the host. You could name this anything, but descriptive names help when managing many servers.
  • ansible_host= tells Ansible the real address to connect to. You are using the private IP because both instances are in the same VPC.
  • Verify the file contents by running:
cat inventory.ini

You should see your managed node's private IP address next to server1 under the [managed] group header.

See the placeholder text instead of your IP?

You need to replace the placeholder with your actual private IP before running the command. Delete inventory.ini with rm inventory.ini and run the cat << EOF command again with the correct IP pasted in.

Help me fix my inventory.ini file

✔️ Awesome, I've got everything!

Great. Make sure both files are saved in ~/ansible-project/. You can double check with ls to see both ansible.cfg and inventory.ini listed.

ⓧ I'd like to double check the full code

Here is what each file should look like.

ansible.cfg:

[defaults]
inventory = inventory.ini
remote_user = ubuntu
private_key_file = ~/.ssh/managed-node-key.pem
host_key_checking = False

inventory.ini (with your actual private IP in place of the example):

[managed]
server1 ansible_host=[[MANAGED_IP="your managed node's private IP (e.g. 172.31.20.45)"]]
Test the connection with Ansible ping

Both configuration files are in place. Time to confirm that Ansible can actually reach your managed node and execute commands on it.

  • Test the connection by running this command:
ansible all -m ping

What does this command do?

  • all targets every host in your inventory.
  • -m ping tells Ansible to use its built-in ping module. This is not a network ping. It connects via SSH, checks that Python is available on the remote host, and returns pong on success.

You should see output like this:

server1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

The word SUCCESS and "ping": "pong" confirm that Ansible connected to your managed node, found Python, and executed a module successfully.

Seeing a connection error or timeout?

If you see UNREACHABLE or a permission denied error, check these common causes:

  • Confirm you are running the command from inside ~/ansible-project/ where your ansible.cfg lives. Ansible only reads the config from the current directory.
  • Verify the private IP in inventory.ini matches the value shown in the EC2 console for your ansible-managed instance.
  • Confirm the key file permissions are correct by running ls -la ~/.ssh/managed-node-key.pem. The permissions should show -rw------- (0600).
  • Make sure the ansible-managed instance's security group allows inbound SSH (port 22) from the control node's private IP or from within the same VPC.

Help me debug my Ansible ping failure

Your Ansible control node can now reach the managed node and run modules on it remotely. Next up, you will write your first real playbook to automate base server configuration.

Write the Base Configuration Playbook

Your control node can talk to the managed node. Now it is time to tell Ansible what to do when it connects. You will write your first playbook, a YAML file that declares a list of tasks for Ansible to execute on the managed node in order.

This playbook will handle base server configuration: updating packages, installing essential tools, and creating a dedicated deploy user with SSH access. Automating these steps means you can rebuild the server from scratch in seconds instead of running dozens of manual commands.

In this step, get ready to:
  • Write a playbook that installs packages and creates a deploy user.
  • Run the playbook and confirm all tasks complete successfully.
  • Verify SSH access to the managed node as the new deploy user.
Create the playbook file with package management tasks

An Ansible playbook starts with a play definition that names the target hosts and whether to escalate privileges. Below the play definition, you list individual tasks that Ansible executes top to bottom.

  • Create base_config.yml in your ~/ansible-project/ directory by running:
nano ~/ansible-project/base_config.yml
  • Paste the following into the file:
---
- name: Base Server Configuration
  hosts: managed
  become: true

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name:
          - htop
          - vim
          - curl
          - git
          - unattended-upgrades
        state: present

What does this code do?

The play header tells Ansible which hosts to target and how to connect:

  • hosts: managed targets the group defined in your inventory.ini.
  • become: true tells Ansible to use sudo for every task, since installing packages and creating users requires root privileges.
  • cache_valid_time: 3600 prevents Ansible from re-downloading the package list if it was refreshed in the last hour.
  • state: present ensures each package is installed. If it already exists, Ansible skips it.
Add user creation and SSH access tasks

Server security best practice is to avoid logging in as the default ubuntu user for day-to-day work. Instead, you create a dedicated user with limited privileges and SSH key-based access.

  • Add the following tasks directly below the Install essential packages task (still inside the tasks: list):
    - name: Create deploy user
      ansible.builtin.user:
        name: deploy
        shell: /bin/bash
        groups: sudo
        append: true
        create_home: true

    - name: Create .ssh directory for deploy user
      ansible.builtin.file:
        path: /home/deploy/.ssh
        state: directory
        owner: deploy
        group: deploy
        mode: "0700"

    - name: Copy SSH public key for deploy user
      ansible.builtin.copy:
        src: ~/.ssh/managed-node-key.pub
        dest: /home/deploy/.ssh/authorized_keys
        owner: deploy
        group: deploy
        mode: "0600"

    - name: Allow deploy user passwordless sudo
      ansible.builtin.lineinfile:
        path: /etc/sudoers.d/deploy
        line: "deploy ALL=(ALL) NOPASSWD:ALL"
        create: true
        mode: "0440"
        validate: "visudo -cf %s"

What does this code do?

These four tasks work together to create a fully functional deploy user:

  • The user module creates the deploy account and adds it to the sudo group.
  • The file module creates a .ssh directory with strict 0700 permissions (owner only).
  • The copy module places the public key into authorized_keys so you can SSH in as deploy without a password.
  • The lineinfile module adds a sudoers entry. The validate parameter runs visudo -cf %s to check syntax before saving, preventing lockouts.
  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.

YAML indentation matters

Each task must be indented with exactly 4 spaces under tasks:. If you see errors about mapping values, check that your indentation lines up with the existing tasks above.

✔️ Awesome, I've got everything!

Great. Make sure you have saved base_config.yml before moving on.

ⓧ I'd like to double check the full code

Here is the complete base_config.yml file:

---
- name: Base Server Configuration
  hosts: managed
  become: true

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install essential packages
      ansible.builtin.apt:
        name:
          - htop
          - vim
          - curl
          - git
          - unattended-upgrades
        state: present

    - name: Create deploy user
      ansible.builtin.user:
        name: deploy
        shell: /bin/bash
        groups: sudo
        append: true
        create_home: true

    - name: Create .ssh directory for deploy user
      ansible.builtin.file:
        path: /home/deploy/.ssh
        state: directory
        owner: deploy
        group: deploy
        mode: "0700"

    - name: Copy SSH public key for deploy user
      ansible.builtin.copy:
        src: ~/.ssh/managed-node-key.pub
        dest: /home/deploy/.ssh/authorized_keys
        owner: deploy
        group: deploy
        mode: "0600"

    - name: Allow deploy user passwordless sudo
      ansible.builtin.lineinfile:
        path: /etc/sudoers.d/deploy
        line: "deploy ALL=(ALL) NOPASSWD:ALL"
        create: true
        mode: "0440"
        validate: "visudo -cf %s"
Run the playbook and verify all tasks complete

With the playbook saved, you can tell Ansible to execute it against your managed node. On the first run, every task should report a status of changed because the server is in its default state.

  • Make sure you are inside the project directory:
cd ~/ansible-project
  • Run the playbook by executing:
ansible-playbook base_config.yml

Ansible connects to the managed node over SSH, executes each task in order, and prints the result. You should see each task name followed by changed in yellow text.

At the end, the PLAY RECAP line should show something like ok=7 changed=6 unreachable=0 failed=0.

Seeing failed tasks?

  • If you see Permission denied, verify that ~/.ssh/managed-node-key.pem has 0600 permissions on the control node.
  • If you see a YAML syntax error, open base_config.yml in nano and check that all tasks are indented with spaces, not tabs.
  • If you see unreachable=1, confirm your managed node's private IP in inventory.ini matches the actual IP shown in the EC2 console.

Still stuck? Help me debug my ansible-playbook run for base_config.yml

Verify SSH access as the deploy user

The playbook created a deploy user and configured SSH key access for it. The true test is connecting to the managed node as that user.

  • From the control node, SSH into the managed node as the deploy user:
ssh -i ~/.ssh/managed-node-key.pem deploy@[[MANAGED_PRIVATE_IP="your managed node's private IP address"]]
  • You should see a new shell prompt showing deploy@ followed by the managed node's hostname.
  • Confirm the user has sudo access by running:
sudo whoami

This should print root, confirming passwordless sudo is working.

  • Exit back to the control node by typing:
exit

Permission denied when connecting as deploy?

  • Make sure ~/.ssh/managed-node-key.pub exists on the control node. The playbook copies this file to the managed node's authorized_keys.
  • Re-run ansible-playbook base_config.yml and check that the Copy SSH public key for deploy user task shows ok or changed (not failed).

Need more help? Help me troubleshoot SSH access for my deploy user

Your managed node now has essential packages installed and a dedicated deploy user with secure SSH access. Next up, you will write a security hardening playbook that locks down SSH, sets up a firewall, and adds intrusion detection.

Write the Security Hardening Playbook

Your server has a deploy user and essential packages, but it's still wide open to attack. Anyone can attempt root login, brute-force passwords, or connect on any port.

In this step, you'll write a security hardening playbook that locks down SSH, installs intrusion detection, and configures a firewall. These are the exact hardening steps that production Linux servers receive on day one.

In this step, get ready to:
  • Write a hardening playbook with SSH lockdown, fail2ban, and UFW firewall tasks.
  • Run the playbook against your managed node.
  • Verify that all hardening measures are active on the server.
Write the SSH hardening and intrusion detection tasks

The first layer of defense is restricting how people can connect to your server. You'll disable root login, turn off password authentication, and install fail2ban to automatically ban IP addresses that fail login attempts.

  • Create harden.yml in your ~/ansible-project/ directory and paste the following code:
---
- name: Security Hardening
  hosts: managed
  become: true

  tasks:
    # Lock down SSH access to key-based only
    - name: Disable root SSH login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^PermitRootLogin"
        line: "PermitRootLogin no"
      notify: Restart SSH

    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^PasswordAuthentication"
        line: "PasswordAuthentication no"
      notify: Restart SSH

What does this code do?

The lineinfile module finds a line matching regexp in the SSH config file and replaces it with the value in line. If no matching line exists, it adds the line to the end of the file.

The notify: Restart SSH keyword tells Ansible to run a handler (defined later in the file) only if the task actually changes something. This means SSH restarts only when the config actually changes, not every time you run the playbook.

  • Below the SSH tasks (still inside the tasks: block), add the fail2ban configuration:
    # Install and configure intrusion detection
    - name: Install fail2ban
      ansible.builtin.apt:
        name: fail2ban
        state: present

    - name: Configure fail2ban for SSH
      ansible.builtin.copy:
        dest: /etc/fail2ban/jail.local
        content: |
          [sshd]
          enabled = true
          port = ssh
          filter = sshd
          maxretry = 3
          bantime = 3600
        mode: "0644"
      notify: Restart fail2ban

What is fail2ban?

fail2ban monitors log files for failed login attempts and automatically bans the offending IP address. Here, maxretry = 3 means three failed SSH attempts trigger a ban.

The bantime = 3600 value bans the IP for 3600 seconds (one hour). The copy module writes the content block directly to /etc/fail2ban/jail.local on the managed node.

You won't see a change yet. The next section completes the playbook with firewall rules and handlers.

Add firewall rules, auto-updates, and handlers

A firewall controls which network ports accept connections. UFW (Uncomplicated Firewall) is the standard firewall interface on Ubuntu.

  • Below the fail2ban tasks (still inside tasks:), add the UFW firewall configuration:
    # Configure UFW firewall - allow SSH first, then enable
    - name: Install UFW
      ansible.builtin.apt:
        name: ufw
        state: present

    - name: Allow SSH through UFW
      community.general.ufw:
        rule: allow
        port: "22"
        proto: tcp

    - name: Enable UFW with default deny
      community.general.ufw:
        state: enabled
        default: deny
        direction: incoming

Order matters here

The "Allow SSH" rule MUST come before "Enable UFW with default deny". If you enable the firewall with default-deny first, it blocks SSH immediately and you lose your connection to the server.

What is community.general.ufw?

The community.general.ufw module is part of the community.general collection. If you installed the full ansible package (not just ansible-core), you already have it.

If you see an error about the module not being found, install the collection by running ansible-galaxy collection install community.general on the control node.

  • Below the UFW tasks, add the automatic security updates task and the handlers section to complete the file:
    # Enable automatic security updates
    - name: Enable automatic security updates
      ansible.builtin.copy:
        dest: /etc/apt/apt.conf.d/20auto-upgrades
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";
        mode: "0644"

  # Handlers run only when notified by a task that changed something
  handlers:
    - name: Restart SSH
      ansible.builtin.service:
        name: ssh
        state: restarted

    - name: Restart fail2ban
      ansible.builtin.service:
        name: fail2ban
        state: restarted

Why use handlers instead of regular tasks?

Handlers only run when a task actually changes something on the server. If you run this playbook a second time and the SSH config hasn't changed, the "Restart SSH" handler won't fire.

This prevents unnecessary service restarts that could briefly interrupt connections. Notice that handlers: is at the same indentation level as tasks: because both are direct children of the play.

  • Save harden.yml (press Ctrl+O, Enter, then Ctrl+X if using nano).
  • Confirm the file exists by running:
ls ~/ansible-project/

You should see harden.yml listed alongside ansible.cfg, inventory.ini, and base_config.yml.

Don't see harden.yml?

Make sure you saved the file inside ~/ansible-project/ and not in your home directory. If you ran nano harden.yml from a different directory, the file will be there instead.

help me find where my harden.yml file was saved

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and it's ready to run.

ⓧ I'd like to double check the full code

Here's the complete harden.yml file. Make sure your version matches this exactly, including indentation:

---
- name: Security Hardening
  hosts: managed
  become: true

  tasks:
    - name: Disable root SSH login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^PermitRootLogin"
        line: "PermitRootLogin no"
      notify: Restart SSH

    - name: Disable password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^PasswordAuthentication"
        line: "PasswordAuthentication no"
      notify: Restart SSH

    - name: Install fail2ban
      ansible.builtin.apt:
        name: fail2ban
        state: present

    - name: Configure fail2ban for SSH
      ansible.builtin.copy:
        dest: /etc/fail2ban/jail.local
        content: |
          [sshd]
          enabled = true
          port = ssh
          filter = sshd
          maxretry = 3
          bantime = 3600
        mode: "0644"
      notify: Restart fail2ban

    - name: Install UFW
      ansible.builtin.apt:
        name: ufw
        state: present

    - name: Allow SSH through UFW
      community.general.ufw:
        rule: allow
        port: "22"
        proto: tcp

    - name: Enable UFW with default deny
      community.general.ufw:
        state: enabled
        default: deny
        direction: incoming

    - name: Enable automatic security updates
      ansible.builtin.copy:
        dest: /etc/apt/apt.conf.d/20auto-upgrades
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";
        mode: "0644"

  handlers:
    - name: Restart SSH
      ansible.builtin.service:
        name: ssh
        state: restarted

    - name: Restart fail2ban
      ansible.builtin.service:
        name: fail2ban
        state: restarted
Run the playbook and verify hardening
  • Run the hardening playbook by executing:
ansible-playbook harden.yml

What should I see?

Ansible outputs each task name with a status. On the first run, you should see changed next to each task because the server is being configured for the first time.

At the end, a recap line shows changed=X with a count of tasks that made modifications. You should also see the handlers (Restart SSH, Restart fail2ban) listed as executed.

Seeing a module not found error for community.general.ufw?

This means the community.general collection isn't installed. Install it by running:

ansible-galaxy collection install community.general

Then re-run ansible-playbook harden.yml.

help me fix this Ansible module not found error

Now verify that the hardening actually took effect on the managed node.

  • Check the SSH configuration by running:
ansible managed -m command -a "grep PermitRootLogin /etc/ssh/sshd_config"

You should see PermitRootLogin no in the output.

  • Check that fail2ban is running:
ansible managed -m command -a "sudo systemctl status fail2ban"

You should see active (running) in the output.

  • Check the UFW firewall status:
ansible managed -m command -a "sudo ufw status"

You should see Status: active with a rule allowing port 22/tcp from anywhere.

UFW status shows inactive?

Check that the "Allow SSH through UFW" task ran before the "Enable UFW" task in your playbook. If the order is wrong, Ansible may have lost the SSH connection when UFW enabled with default-deny and no SSH exception.

Also verify your YAML indentation. Each task should start with four spaces, then a dash and a space.

help me debug why UFW isn't active on my managed node

Your server is now hardened with four layers of protection: SSH lockdown, intrusion detection, a firewall, and automatic security updates. Next up, you'll create a master playbook that orchestrates both your base configuration and hardening in a single command.

Create the Master Playbook and Demonstrate Idempotency

Your managed node is fully hardened with SSH lockdown, fail2ban, and a firewall. Each playbook you wrote handles one piece of the puzzle.

In this step, you'll combine both playbooks into a single master playbook that runs your entire automation pipeline with one command. Then you'll prove the pipeline is idempotent. No matter how many times you run it, Ansible only makes changes when the server has actually drifted from the desired state.

In this step, get ready to:
  • Write a master playbook that imports both playbooks in sequence.
  • Run the master playbook twice to demonstrate idempotency.
  • Document your automation with inline comments.
Write the master playbook

Right now, running your full automation requires two separate commands. A master playbook solves this by importing both playbooks in the correct order.

  • Create site.yml inside your ~/ansible-project/ directory by running:
nano site.yml
  • Paste the following content into the file:
---
- import_playbook: base_config.yml
- import_playbook: harden.yml

What does this playbook do?

The import_playbook directive pulls in another playbook file and runs all of its plays in order. Ansible processes the imports at parse time, so the execution order is guaranteed.

Base configuration runs first (packages, user creation, SSH keys), then hardening applies on top (SSH lockdown, fail2ban, firewall). This order matters because hardening depends on the deploy user existing first.

  • Save the file by pressing Ctrl+O, then Enter, then Ctrl+X to exit nano.
  • Confirm the file was saved correctly by running:
cat site.yml

You should see both import_playbook lines with base_config.yml listed first and harden.yml second.

File looks empty or wrong?

Make sure you pressed Ctrl+O (the letter O, not zero) to save before exiting nano. If the file is empty, run nano site.yml again and re-paste the content.

Help me fix my site.yml file.

Run the master playbook and demonstrate idempotency

A well-written Ansible playbook is idempotent. Running it once configures the server. Running it again does nothing, because the server already matches the desired state.

  • Run the master playbook for the first time:
ansible-playbook site.yml

Since your managed node is already configured from previous steps, most tasks should report ok. You may see a few changed results if the apt cache has expired.

What do the status colours mean?

Green ok means the task checked the server and found it already in the desired state. Yellow changed means Ansible made an actual modification. Red failed means something went wrong.

The PLAY RECAP at the bottom tallies these counts for each host.

  • Run the exact same command a second time:
ansible-playbook site.yml

This time, look at the PLAY RECAP line at the bottom. You should see changed=0 for every play. Every task reports ok because the server already matches the desired state.

Why does idempotency matter?

In production, teams run playbooks on schedules or as part of CI/CD pipelines. If a playbook is idempotent, you can safely re-run it at any time without fear of breaking a working server. It acts as a self-healing check that corrects drift automatically.

If someone manually changes the SSH config on the managed node, the next playbook run will detect the drift and fix it back to the desired state.

Seeing changed tasks on the second run?

The Update apt cache task may show changed if more than 3600 seconds (1 hour) have passed since the last cache update. This is expected. All other tasks should show ok.

If other tasks show changed, check that you haven't manually modified files on the managed node between runs.

Help me understand why my second run still shows changes.

Document your playbooks with comments

Your automation works. The final step is documenting it so that anyone reading your playbooks (including future you) understands what each section accomplishes.

In YAML, comments start with # and continue to the end of the line. Ansible ignores them completely during execution.

  • Open base_config.yml in nano:
nano base_config.yml
  • Add comments above each logical section of tasks explaining what it does and why. For example, add a comment above the Create deploy user task explaining that this user will be the primary account for deployments.
  • Save and exit nano.
  • Repeat the same process for harden.yml:
nano harden.yml
  • Add comments explaining each hardening section: SSH lockdown, fail2ban configuration, firewall rules, and automatic updates.
  • Save and exit nano.

Good comment habits

Focus on explaining WHY, not WHAT. The task names already describe what each task does. Your comments should explain the reasoning. For example: # Deny all incoming by default so only explicitly allowed services are reachable is more useful than # Enable UFW.

  • Verify your comments didn't break anything by running the master playbook one final time:
ansible-playbook site.yml

The output should still show changed=0 across all plays. Comments are invisible to Ansible, so your automation runs identically.

Playbook fails after adding comments?

YAML is sensitive to indentation. Make sure your # comments start at the same indentation level as the line below them. A misplaced character can break the file structure.

Run ansible-playbook site.yml --syntax-check to validate the YAML before executing.

Help me fix the syntax error in my playbook.

✔️ Awesome, I've got everything!

Great. Make sure you've saved both files after adding your comments.

ⓧ I'd like to double check the full code

Here is the complete site.yml for comparison. Your base_config.yml and harden.yml should match the versions you created in the previous steps, with your own comments added.

---
- import_playbook: base_config.yml
- import_playbook: harden.yml

Your full automation pipeline is complete. You have a single command that provisions and hardens a Linux server from scratch. This is exactly the kind of repeatable, documented automation that employers look for in Linux admin and DevOps roles.

Secret mission

Refactor into a Reusable Ansible Role

Your hardening playbook works, but it's a flat file with hardcoded values. In production, teams share automation as reusable roles with configurable variables. Can you restructure your playbook into a proper Ansible role that anyone can customize without editing the tasks?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. Both EC2 instances are covered by your AWS free tier credits while running, consuming approximately $0.02/hour total.

Cost warning

While your t3.micro instances are covered by free tier credits, leaving them running continuously will consume those credits over time. If you are done with this project, terminate both instances now to preserve your remaining credits for future projects.

Resources you used:

  • EC2 instance: ansible-control (t3.micro, Ubuntu Server 24.04 LTS).
  • EC2 instance: ansible-managed (t3.micro, Ubuntu Server 24.04 LTS).
  • EC2 key pair: managed-node-key.

Keep everything running

No action needed. Choose this if you are still actively building or want to experiment further with your Ansible playbooks.

  • Both EC2 instances will continue running and consuming free tier credits at approximately $0.02/hour total.
  • You can SSH back into your control node at any time using your key pair.
  • Your playbooks and role are stored on the control node and ready to re-run.

Pause - I'll come back to this later

Stop both instances to free up credits while preserving your files. Stopped instances do not incur compute charges (only minimal EBS storage charges).

  • Disconnect from your SSH session by typing exit in your terminal.
  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, choose Instances.
  • Select both ansible-control and ansible-managed.
  • Choose Instance state, then Stop instance.
  • Choose Stop when prompted for confirmation.

How to resume later

To restart, select your instances and choose Instance state then Start instance. Note that the public IP address of your control node may change after stopping and restarting.

Delete - I don't want to use this again

Remove all project resources completely. This stops all charges immediately. You can always relaunch fresh instances and re-run your playbooks to recreate everything from scratch. That is the power of automation.

  • Disconnect from your SSH session by typing exit in your terminal.

Terminate both EC2 instances:

  • Open the EC2 console at https://console.aws.amazon.com/ec2/.
  • In the navigation pane, choose Instances.
  • Select both ansible-control and ansible-managed.
  • Choose Instance state, then Terminate (delete) instance.
  • Choose Terminate (delete) when prompted for confirmation.

Delete the key pair:

  • In the EC2 console navigation pane, choose Key Pairs.
  • Select managed-node-key and choose Actions, then Delete.
  • In the confirmation field, enter Delete and then choose Delete.

What about the .pem file on my computer?

The managed-node-key.pem file you downloaded to your local machine during setup is safe to delete manually. Navigate to wherever you saved it and remove it.

Nice Work!

Nice Work!

Nice work! You just built a complete server hardening automation pipeline using Ansible and AWS EC2.

You've learned how to:

  • Launched two EC2 instances and configured Ansible on a control node to manage remote infrastructure over SSH.
  • Built a security hardening playbook implementing SSH lockdown, fail2ban intrusion detection, UFW firewall rules, and automatic security updates.
  • Demonstrated idempotency by running playbooks multiple times with zero drift, proving your automation is reliable and repeatable.
  • Secret Mission: Refactored your automation into a production-grade Ansible role with variables, Jinja2 templates, and handlers.

Ready to quiz yourself?