How to Build a Fresh Server with One Ansible Playbook (Tutorials 1 to 40, Automated)

Tutorial 74 rebuilt a server by hand from this series. With Ansible (139) the whole baseline — users, keys, hardening, Docker, drives, backups, monitoring, the compose stacks from Gitea — becomes a playbook you run against a bare install. A dead SSD, a new mini PC, a second Pi: one command and a coffee. This is also the best documentation of "how is my server set up" you'll ever have, because it is the setup.
Quick reference
| Piece | What |
|---|---|
| Layout | ~/ansible/ with inventory.ini, site.yml, roles/*/tasks/main.yml |
| Roles | base, hardening, docker, storage, backup, monitoring, apps |
| First run on a fresh box | ansible-playbook site.yml --limit newbox -K |
| Re-run any time | ansible-playbook site.yml — only drift is changed |
| Secrets | group_vars/all/vault.yml (ansible-vault) |
| Source | the whole folder in Gitea |
1. Structure
~/ansible/
ansible.cfg
inventory.ini
site.yml
group_vars/all/vars.yml
group_vars/all/vault.yml
roles/
base/tasks/main.yml
hardening/tasks/main.yml
docker/tasks/main.yml
storage/tasks/main.yml
backup/tasks/main.yml
monitoring/tasks/main.yml
apps/tasks/main.yml
site.yml just lists the roles:
---
- hosts: all
become: true
roles:
- base
- hardening
- docker
- storage
- backup
- monitoring
- apps
Variables in group_vars/all/vars.yml:
admin_user: john
admin_key: "ssh-ed25519 AAAA... john-laptop"
timezone: Europe/London
lan_subnet: 192.168.1.0/24
docker_repo: ssh://git@192.168.1.60:2222/john/docker.git
Secrets (ansible-vault create group_vars/all/vault.yml): the ntfy token, restic password, SMTP app password.
2. The roles, briefly
base (tutorials 4, 5, 9):
- apt: { update_cache: true, upgrade: dist }
- apt: { name: [htop, tmux, ncdu, curl, git, smartmontools, msmtp, msmtp-mta, mailutils], state: present }
- community.general.timezone: { name: "{{ timezone }}" }
- user: { name: "{{ admin_user }}", groups: sudo, append: true, shell: /bin/bash }
- authorized_key: { user: "{{ admin_user }}", key: "{{ admin_key }}" }
- template: { src: msmtprc.j2, dest: /etc/msmtprc, mode: "0600" }
hardening (3, 6, 7, 36):
- lineinfile: { path: /etc/ssh/sshd_config, regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
notify: restart ssh
- lineinfile: { path: /etc/ssh/sshd_config, regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
notify: restart ssh
- community.general.ufw: { rule: allow, name: OpenSSH }
- community.general.ufw: { state: enabled, policy: deny }
- apt: { name: [fail2ban, unattended-upgrades], state: present }
- template: { src: jail.local.j2, dest: /etc/fail2ban/jail.local }
notify: restart fail2ban
- copy: { src: 50unattended-upgrades, dest: /etc/apt/apt.conf.d/50unattended-upgrades }
docker (24, 67, 105):
- shell: curl -fsSL https://get.docker.com | sh
args: { creates: /usr/bin/docker }
- user: { name: "{{ admin_user }}", groups: docker, append: true }
- copy: { src: daemon.json, dest: /etc/docker/daemon.json }
notify: restart docker
storage (22): mount tasks by UUID with nofail, per host, from host_vars/homeserver.yml.
backup (23, 55, 104): copy backup.sh, a systemd timer (69), the restic env file from the vault.
monitoring (56, 66): smartd.conf template, the Netdata or node_exporter compose stack, the Dozzle agent on Pis.
apps (25, 45, 103):
- git:
repo: "{{ docker_repo }}"
dest: "/home/{{ admin_user }}/docker"
accept_hostkey: true
become_user: "{{ admin_user }}"
- copy: { content: "{{ vault_env_files[inventory_hostname] }}", dest: "/home/{{ admin_user }}/docker/.env", mode: "0600" }
- community.docker.docker_compose_v2:
project_src: "/home/{{ admin_user }}/docker/{{ item }}"
loop: "{{ stacks }}"
with stacks listed per host (host_vars/homeserver.yml: stacks: [pihole, nextcloud, jellyfin, ...]) and the .env contents in the vault.
3. Run it on a fresh install
Install Ubuntu (tutorial 1) with your user and SSH on, add the box to inventory.ini, then:
ssh-copy-id john@192.168.1.70
ansible-playbook site.yml --limit newbox -K
Ten minutes later: hardened, Docker running, drives mounted, backups scheduled, monitoring reporting, every compose stack up with its data folders ready for a restore (65). Restore the data, re-run the apps role, done.
4. Run it on the existing server
Run with --check --diff first; it shows every place the real server differs from the playbook — the undocumented tweaks. Fold them into the playbook until --check is clean. From then on the playbook is the truth and the server is a copy of it.
5. Keep it in git
git init in ~/ansible, push to Gitea. The vault file is encrypted; everything else is readable, which is the point — the repository is the documentation BookStack (88) describes in prose.
Troubleshooting
Role order matters. Docker before apps; base before anything using admin_user. If a run fails halfway, fix and re-run — idempotence means it picks up where it left off.
docker_compose_v2 module missing. ansible-galaxy collection install community.docker.
-K sudo password every run. Add a sudoers NOPASSWD line for your user in the base role (lineinfile into /etc/sudoers.d/admin, with validate: visudo -cf %s).
Vault password prompt in scripts. --vault-password-file ~/.vault_pass (chmod 600).
Wrapping up
Seven roles that mirror the series, variables for what differs per host, secrets in a vault, the folder in Gitea. A new or rebuilt machine is one command; the existing one gets checked against the playbook until nothing undocumented remains. It's the end state the whole series has been building towards: a server you could lose on Monday and have back by Monday lunchtime.
