Foundations / Chapter 5

By the end of this chapter you should be able to

  • Explain what idempotent means, and why it is the property that makes automation safe to rerun
  • Describe your server's setup as a file instead of as a memory
  • Run that description twice and understand why the second run changes nothing
  • Detect and correct a change somebody made by hand

Without scrolling back, write down every change you have made to your server. Most people get about seven, in the wrong order, and miss the one that matters. Two chapters ago you had no way of knowing which those were.

If the machine dies, or you buy a bigger one, or you break something at 11pm, none of that is a procedure yet. It is you, remembering.

What we actually have #

A list of things that were true once:

  • a hardening file in /etc/ssh/sshd_config.d/, whose name mattered for a reason you would have to remember
  • a firewall policy, and one rule scoped to a subnet
  • an upgrade configuration with a reboot setting you chose deliberately
  • a hostname and a timezone

Four bullet points. The problem is not that this is a lot. The problem is that it lives in one place, that place is the server, and there is no way to ask "is it still like that?" other than checking each one by hand.

That question has a name. When a machine stops matching what you believe about it, because of an emergency fix nobody wrote down or a package upgrade that replaced a config file, that is drift. Drift is why people stop touching a working server.

Two ways to write down a setup #

The obvious approach is a script: the commands you ran, in order, in a file. It is a real improvement over memory, and if you stop reading here and write one, you are ahead of most home servers.

But run it twice and the trouble shows. The second run appends your SSH rule to the firewall again. It appends your key to authorized_keys again. Some commands fail because the thing already exists, and now you cannot tell a real failure from a harmless one. So you stop rerunning it. It becomes documentation nobody checks, and it stops being true.

The other approach is to describe the state you want rather than the steps to reach it. Say "this file should contain this" rather than "append this line", and "this rule should exist" rather than "run ufw allow". Something else works out what to change, and if nothing needs changing, it does nothing.

That property has a name too. An operation is idempotent when doing it twice has the same effect as doing it once.

The same description, applied twice On the first run, each task compares the machine against the description and changes what does not match. On the second run the comparison still happens, finds nothing to do, and reports ok rather than changed. A second run reporting no changes is a statement that the machine matches. What you said timezone is Paris this file, this content port 22 from the LAN First run compare timezone is UTC → change it file missing → write it no rule → add it changed = 3 the machine moved Second run compare timezone already Paris file already correct rule already there changed = 0 the machine matches Nothing was touched in between. The second run did not skip anything: it checked every task and found nothing to do, which is a stronger claim than "the script exited without errors".
The same description, run twice. The second run reports what it found rather than what it did.

Why Ansible #

Ansible is a tool for describing machine state in YAML and applying it over SSH. It has no agent to install on the server: it connects the same way you do, runs some Python, and disconnects.

That last point is why it fits here. You already have key-based SSH working. Ansible needs nothing else.

Why not just write a careful shell script?Show me why

You can. Idempotence is not magic, it is checking before you act, and you can write those checks yourself: test whether the line is already in the file, whether the rule already exists, whether the package is already installed.

The catch is that you have to write that checking for every single task, get it right every time, and maintain it. Ansible's modules have already done it. ansible.builtin.copy knows how to compare a file's content and permissions and do nothing if they already match. ansible.builtin.lineinfile knows how to not add a duplicate.

Ansible's modules are several hundred small pieces of correctness you would otherwise write yourself.

The honest cost of Ansible: YAML, which is a fussy format; a vocabulary of modules, plays, tasks and handlers; and documentation written for people managing five hundred machines when you have one. This chapter uses a small corner of it and ignores the rest.

Side readingAnsible without the enterpriseThe fraction of Ansible a home server needs, and the vocabulary its documentation assumes you already have.

Set it up on your laptop #

Ansible runs on the machine you sit at, not the one you are configuring. That machine is called the control node. Install it there:

$ sudo apt install ansible-core          # Debian, Ubuntu
$ brew install ansible                   # macOS
$ ansible --version
$ ansible-galaxy collection install community.general

The timezone and firewall modules used below belong to community.general, which is not part of ansible-core. Install the collection before the first playbook run.

Then make a directory for this, somewhere in your own files. It is going to become a git repository.

$ mkdir -p ~/homeserver && cd ~/homeserver

Where this repository lives, from here on

Two copies, and it is worth settling now because later chapters use both.

The canonical copy is on your laptop. That is where you edit, where you commit, and where you run ansible-playbook from, because Ansible configures the host from outside.

From chapter 6 onward you will also want a deployment copy on the server, at ~/homeserver, because that is where docker compose runs and where the secret-rendering script reads from. This chapter uses rsync after each committed change; it does not assume a Git host that you have not configured.

If you would rather keep one copy, put it on the server and run Ansible from there against localhost. That works and costs you the ability to fix the machine when it is not reachable.

Two files to start. The first says which machines exist:

inventory.yml
all:
  hosts:
    homeserver:
      ansible_host: 192.168.1.20
      ansible_user: admin

The second says what should be true about them:

site.yml
---
- name: Baseline
  hosts: all
  become: true
  tasks:
    - name: Set the timezone
      community.general.timezone:
        name: Europe/Paris

become: true means use sudo for the tasks. hosts: all means every machine in the inventory, which for now is one.

Check that Ansible can reach the machine before asking it to change anything:

$ ansible -i inventory.yml all -m ping

That module is not an ICMP ping. It connects over SSH, runs a tiny Python program, and reports back. A SUCCESS proves your key, user and remote Python work. Test privilege escalation separately:

$ ansible -i inventory.yml all --become --ask-become-pass -m command -a whoami

The command should report root. If it does not, fix sudo before running the playbook.

Ansible cannot connect to my server

Work out which part of the connection is failing

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

ansible -i inventory.yml all -m ping is failing and I am not sure whether the problem is my inventory, my SSH configuration, the user, sudo, or Python on the server. Plain ssh to the machine works.

Run it twice #

The first run:

$ ansible-playbook -i inventory.yml site.yml --ask-become-pass

The second, immediately after, with nothing touched in between:

$ ansible-playbook -i inventory.yml site.yml --ask-become-pass

On the first run, expect declared resources that differ to report changed. On the second, require the play recap to report changed=0. The second run still checks each task; it should find the declared state already present.

For the tasks present in this playbook, changed=0 means "I checked these declared resources, and none needed changing". It does not discover an old firewall rule whose task you removed or state you never modelled.

Why is `changed=0` more useful than a script that exits without errors?Show answer

Because they answer different questions. A script exiting cleanly tells you its commands did not fail. changed=0 tells you the machine currently matches your description, which is the thing you actually wanted to know.

A script can succeed on a machine where somebody deleted your firewall rule an hour ago. It would happily add it back and report success, and you would learn nothing about the fact that it had gone. Ansible reports changed=1 and you know something drifted.

Move the rest of it in #

Now the real work: everything from chapters 3 and 4, as tasks.

site.yml
---
- name: Baseline
  hosts: all
  become: true

  tasks:
    - name: Install automatic security updates
      ansible.builtin.apt:
        name: unattended-upgrades
        state: present
        update_cache: true

    - name: Enable the daily unattended-upgrade policy
      ansible.builtin.copy:
        dest: /etc/apt/apt.conf.d/20auto-upgrades
        owner: root
        group: root
        mode: "0644"
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";

    - name: Set the hostname
      ansible.builtin.hostname:
        name: "{{ inventory_hostname }}"

    - name: Set the timezone
      community.general.timezone:
        name: "{{ server_timezone }}"

    - name: Harden the SSH daemon
      ansible.builtin.copy:
        src: files/10-hardening.conf
        dest: /etc/ssh/sshd_config.d/10-hardening.conf
        owner: root
        group: root
        mode: "0644"
        validate: /usr/sbin/sshd -t -f %s
      notify: Reload ssh

    - name: Only take security updates, and never reboot on your own
      ansible.builtin.copy:
        src: files/52-upgrades
        dest: /etc/apt/apt.conf.d/52-upgrades
        owner: root
        group: root
        mode: "0644"

    - name: Deny incoming by default
      community.general.ufw:
        direction: incoming
        policy: deny

    - name: Allow outgoing by default
      community.general.ufw:
        direction: outgoing
        policy: allow

    - name: Allow SSH from the local network
      community.general.ufw:
        rule: allow
        src: "{{ lan_subnet }}"
        port: "22"
        proto: tcp
        comment: SSH from the LAN

    - name: Turn the firewall on
      community.general.ufw:
        state: enabled

  handlers:
    - name: Reload ssh
      ansible.builtin.service:
        name: ssh
        state: reloaded

Four things in that file.

validate: on the SSH file. Before Ansible puts that file in place, it runs sshd -t against the candidate. If the file is malformed, the task fails and the old file stays. This is chapter 3's habit, automated.

Automating the thing that can lock you out

This playbook rewrites your SSH configuration and your firewall. A mistake here reaches the machine faster than a mistake by hand, because there is no pause between deciding and doing.

Keep the second session open, exactly as before. Run with --check first, every time, until you trust it.

notify: and handlers. The SSH task does not reload the service. It notifies a handler, which runs once at the end of the play, and only if something actually changed. Rerun with nothing to do and the service is not reloaded, because there is nothing to reload it for.

community.general prefixes. Ansible ships a small core, and everything else lives in collections. You installed this collection before the first run because even the initial timezone task needs it.

{{ lan_subnet }} is a variable, so the subnet is written once. Put it in the inventory next to the host:

inventory.yml
all:
  hosts:
    homeserver:
      ansible_host: 192.168.1.20
      ansible_user: admin
  vars:
    lan_subnet: 192.168.1.0/24
    server_timezone: Europe/Paris

The files referenced with src: go in a files/ directory beside the playbook. Copy them from the server, or paste in what you wrote in chapter 3.

Look before you leap #

Two flags turn this from something you hope works into something you can inspect.

$ ansible-playbook -i inventory.yml site.yml --ask-become-pass --check --diff

--check asks supporting modules what they would change without applying those changes. Unsupported modules may skip work, and tasks that depend on an earlier change can report misleading results. --diff can also print file contents, including secrets, so do not capture or paste it blindly.

Not every module supports check mode faithfully, and a task that depends on an earlier task's effect can report oddly in a dry run. But for a playbook that is mostly files and firewall rules, it is close to the truth and worth running every single time.

Watch it catch drift #

Go to the server and change something by hand:

$ sudo sed -i 's/^MaxAuthTries.*/MaxAuthTries 42/' /etc/ssh/sshd_config.d/10-hardening.conf

Then, from your laptop:

$ ansible-playbook -i inventory.yml site.yml --ask-become-pass --check --diff

Require the SSH task to show a diff from the server's MaxAuthTries 42 to the repository's intended value, report a pending change, and leave the server file untouched because of --check. Drop --check only after reviewing that real diff.

Read the two header lines carefully, because they are not what you would guess. before is the file on your server. after is the file in your repository. Ansible is showing you the direction it is about to push, not two versions of the same file.

You can now ask your server whether it still matches what you meant, and correct it if not. That gets more valuable with every service you add.

My playbook says changed every time

Find out which task is not idempotent and why

Paste this into a new agent session. It carries everything the agent needs to know about where you are, and asks it to walk you through the problem rather than fix it for you.

One of my Ansible tasks reports changed on every run even when nothing on the server is different. I would like to work out which task it is and why it thinks something changed, rather than ignoring it.

Put it in git #

$ cd ~/homeserver
$ git init
$ git add inventory.yml site.yml files/
$ git commit -m "Baseline: SSH, firewall, updates"

Create the deployment directory and copy the committed configuration to the server:

$ ssh admin@192.168.1.20 'mkdir -p ~/homeserver'
$ rsync -az --exclude=.git --exclude=.env --exclude='*.key' --exclude='*.pem' \
    ~/homeserver/ admin@192.168.1.20:~/homeserver/

Run the same rsync command after later commits. It does not use --delete, so server-only certificate and rendered-secret files are not silently removed. Review stale files yourself when a configuration file is renamed.

Now you have history. Six months from now, git log tells you when you changed the firewall and, if you write decent commit messages, why.

The rule that starts here and gets enforced later

Nothing secret goes in this repository. No passwords, no API tokens, no private keys. Right now there are none, which makes this an easy promise to keep.

It stops being easy the moment you add an application with a database password. There is a chapter on doing that properly. Until then, if you find yourself about to commit a secret, do not, and make a note that this is the problem that chapter solves.

Add a .gitignore now, while the repository is empty enough for it to be a decision rather than a cleanup:

.gitignore
*.key
*.pem
*.retry
.env

Note what is not excluded. Chapter 9 adds a secrets/ directory whose contents are encrypted, and commits it deliberately. What must never be committed is plaintext.

What this bought you #

A new machine is now a shorter conversation. Install the operating system, copy your key over, put the address in the inventory, run the playbook. The four things from the start of this chapter happen in about ninety seconds, in the right order, without you remembering any of them.

It is not a full rebuild. Your data is not here, and neither is anything you will install from chapter 6 onwards. But the foundation is a file now, and that file is the thing you would have been unable to reconstruct.

Done when

  • ansible -i inventory.yml all -m ping succeeds
  • ansible -i inventory.yml all --become --ask-become-pass -m command -a whoami reports root
  • site.yml contains the SSH hardening, the upgrade policy, the firewall rules, the hostname and the timezone
  • Running the playbook twice gives changed=0 the second time
  • The SSH task has a validate: and notifies a handler rather than reloading directly
  • --check --diff runs cleanly and you have read its output
  • You changed something by hand, saw the playbook detect it, and put it back
  • It is all in a git repository with a .gitignore and at least one commit
  • The repository has been copied to ~/homeserver on the server with the documented rsync command
  • Nothing secret is in that repository

What you picked up

  • Changes made by hand live in one place and cannot be verified. That is drift.
  • A script tells you its commands did not fail. A description tells you the machine matches what you meant.
  • Idempotent means running it twice is the same as running it once, which is what makes it safe to rerun without thinking.
  • changed=0 is a statement about your server, not about the run.
  • --check --diff before every real run, especially on the parts that can lock you out.
  • Validate configuration before installing it, and reload services only when something actually changed.
  • The foundation is now a file. The data is not, and that is a different chapter.

Settings

Your values

The book is written with placeholder names so it makes sense to everybody. Put your own in and every chapter, every command and every copy-paste prompt updates to match.

Nothing here is sent anywhere. It is saved in this browser, so it comes back next time. A different browser or a private window gets the placeholders again.

Live preview

$ ssh admin@192.168.1.20
$ sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp
$ sudo hostnamectl set-hostname homeserver
$ sudo timedatectl set-timezone Europe/Paris

Real commands from chapters 2, 3 and 4. They change as you type.

The account you log in as. Not root, and not necessarily the same name you use on your laptop.

Introduced in Chapter 2, Meet your server

The book's placeholder is admin

What the machine calls itself. You choose it, and it shows up in your shell prompt and your logs.

Introduced in Chapter 3, A safe front door

The book's placeholder is homeserver

The IP address your server has on your home network, from ip -brief addr.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.20

The address range and prefix shown by ip route or ip -brief addr, written in CIDR form. Copy the real prefix; do not guess /24.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.0/24

The address traffic goes to on its way out of your house, from ip route.

Introduced in Chapter 2, Meet your server

The book's placeholder is 192.168.1.1

In Region/City form, or Etc/UTC if you would rather read logs in UTC.

Introduced in Chapter 3, A safe front door

The book's placeholder is Europe/Paris

A registered name you control. Chapter 8 uses it for the LAN route; chapter 12 uses a separate private Tailscale name remotely.

Introduced in Chapter 8, One door, many rooms

The book's placeholder is example.com

The email identity allowed to administer the tagged server in your Tailscale policy.

Introduced in Chapter 10, Your own private network

The book's placeholder is you@example.com

The mailbox that should receive actionable home-server alerts.

Introduced in Chapter 14, Knowing it is alive

The book's placeholder is alerts@example.com

Once you save, the prose and the commands read with your names, the copy buttons copy your values, and the copy-paste prompts describe your machine accurately. That last one matters: an assistant told your network is 192.168.1.0/24 when it is not will send you chasing the wrong thing.

Anything you leave empty keeps the book's placeholder.