# Server Infrastructure-as-code and service definitions for luke-else.co.uk's self-hosted server estate: three [Hetzner Cloud](https://www.hetzner.com/cloud/) VPS instances provisioned with [OpenTofu](https://opentofu.org/), bootstrapped and deployed with [Ansible](https://www.ansible.com/), running Docker Compose stacks behind [Traefik](https://traefik.io/traefik/).

## Contents - [Architecture](#architecture) - [Repository layout](#repository-layout) - [Design choices](#design-choices) - [Prerequisites](#prerequisites) - [Quickstart](#quickstart) - [Provisioning the infrastructure](#provisioning-the-infrastructure-infra) - [Bootstrapping and deploying](#bootstrapping-and-deploying-ansible) - [Running the services](#running-the-services-services) - [Service inventory](#service-inventory) - [Caveats](#caveats) - [Security notes](#security-notes) ## Architecture Three servers, one shared private network: ```mermaid architecture-beta group cloud(cloud)[Hetzner] group network(cloud)[network] in cloud service dev(mdi:server)[dev] in network service prod(mdi:server)[prod] in network service prodfirewall(mdi:firewall)[firewall] in cloud service vpn(mdi:server)[vpn] in cloud service vpnfirewall(mdi:firewall)[firewall] in cloud service gateway(mdi:web)[gateway] in cloud service backups(mdi:bucket)[S3 Backups] dev:L -- R:prod prod:B -- T:prodfirewall vpn:B -- T:vpnfirewall prodfirewall: L -- R: gateway vpnfirewall: B -- T: gateway dev:T -- B:backups prod:T -- B:backups vpn:R -- L:backups ``` `gateway` is the public internet, not a provisioned resource. `backups` is the S3-compatible bucket every host's `backup-docker-compose.yml` pushes to and restores from (see [Design choices](#design-choices)) — also not a Hetzner resource. | Server | Purpose | Network | |---|---|---| | `dev` | Gitea, CI runners, dev-facing Traefik | Private network only | | `prod` | Public websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | | `vpn` | WireGuard (wg-easy) + its own Traefik | **Not** on the private network — isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` | `dev` and `prod` share a private Hetzner network (`10.0.1.0/24` by default) so CI/CD on `dev` can reach `prod` without exposing that traffic publicly. Each server has its own Hetzner Cloud Firewall (`infra/modules//main.tf`) opening only the ports its own compose stacks use, plus SSH restricted to `var.allowed_ssh_source_ips`. ## Repository layout ``` . ├── infra/ # OpenTofu — provisions the 3 servers, network, firewalls, DNS │ ├── main.tf # root module: wires network + dev/prod/vpn/dns modules together │ ├── variables.tf # shared inputs (sizes, locations, IP ranges, SSH key names) │ ├── terraform.tfvars.example │ └── modules/ │ ├── network/ # shared private network + subnet (dev + prod) │ ├── dev/ prod/ vpn/ # one server + firewall each, labeled role= │ └── dns/ # Hetzner DNS zones + records for every domain in var.dns_zones ├── ansible/ # bootstraps each server, deploys/starts services// onto it │ ├── inventory/hcloud.yml # dynamic inventory — queries the Hetzner API, groups by role label │ ├── roles/ │ │ ├── bootstrap/ # Docker, deploy user, sshd hardening, unattended-upgrades │ │ └── deploy/ # copies services//, renders .env + Runners/docker-compose.yml │ └── playbooks/ # bootstrap.yml, deploy.yml, spinup.yml, spindown.yml, site.yml ├── services/ # Docker Compose stacks, grouped by which server they run on │ ├── dev/ # Gitea + CI runners + Traefik + backup/restore │ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik + backup/restore │ └── vpn/ # WireGuard (wg-easy) + Traefik + backup/restore ├── .devcontainer/ # git submodule: shared devcontainer for this repo (OpenTofu tooling) └── assets/ ``` Each of `services/dev`, `services/prod`, `services/vpn` follows the same convention: one `*-docker-compose.yml` (or subdirectory, e.g. `Runners/`) per service, a `backup-docker-compose.yml` + `restore.sh` pair, and a `spinup.sh` / `spindown.sh` pair that brings up or tears down everything on that host in order. `infra/modules/` and `ansible/roles/deploy` mirror the same dev/prod/vpn split, so a given host's cloud resources, bootstrap/deploy logic, and compose stacks sit side by side. ## Design choices - **OpenTofu and Ansible have a clean split**: OpenTofu only ever provisions cloud resources (servers, network, firewalls, DNS) and never touches a server over SSH. Everything from "the server exists" onward — Docker, the `deploy` user, SSH hardening, copying `services//`, running `spinup.sh`/`spindown.sh` — is Ansible's job. See [`ansible/README.md`](ansible/README.md). - **No Hetzner Cloud Volumes.** Every stateful service mounts a plain named Docker volume instead. Each host backs its volumes up nightly to S3 and restores any that are empty on first boot — see [Persistent data](#persistent-data). - **`vpn` is deliberately off the private network.** SSH to `dev`/`prod` is only reachable through it (see [Security notes](#security-notes)), but it can't reach either directly — containing a compromised VPN endpoint. - **DNS is provisioned, not just documented.** `module.dns` creates a Hetzner zone and every A/MX/TXT/CNAME record in `infra/main.tf` — see [Managing DNS](#managing-dns). - **Hosts are discovered dynamically** from the Hetzner API by role label, not a static inventory file — nothing in Ansible reads Terraform state. - **Gitea Actions runners are disposable.** Their `/data` is a local cache; runner identity re-registers with Gitea on every `spinup.sh`, so they're excluded from backup. ## Prerequisites - A [Hetzner Cloud](https://console.hetzner.cloud/) project and API token - One or more SSH keys uploaded to that project (Console → Security → SSH Keys), plus the matching private key available locally - [OpenTofu](https://opentofu.org/docs/intro/install/) `>= 1.6.0` - [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) `>= 2.15` and the `hetzner.hcloud` collection (`ansible-galaxy collection install -r ansible/requirements.yml`) - Ownership of the domains in `var.dns_zones` at whatever registrar they're bought through, so you can point their NS records at Hetzner - An S3-compatible bucket (e.g. [Hetzner Object Storage](https://www.hetzner.com/storage/object-storage/)) and an access key/secret pair — not provisioned by OpenTofu, so create this yourself ## Quickstart [`control.sh`](control.sh) at the repo root is an interactive menu covering the whole setup: ```sh ./control.sh ``` It offers **Check prerequisites**, a **Configuration** section (prompts for `HCLOUD_TOKEN`, `DEPLOY_USER`, `ANSIBLE_SSH_PRIVATE_KEY_FILE`, and the `BACKUP_S3_*` credentials, saved to gitignored `.control.env` so you only enter them once), individual OpenTofu/Ansible actions, and a **Guided full setup** that runs everything in the firewall-imposed order — `vpn` first, pause for you to connect, then `dev`/`prod`. It's just a wrapper around the `tofu`/`ansible-playbook` commands below, so you can always drop to running them by hand. ## Provisioning the infrastructure (`infra/`) ```sh cd infra export HCLOUD_TOKEN=your-hetzner-api-token # never commit this cp terraform.tfvars.example terraform.tfvars $EDITOR terraform.tfvars # set ssh_key_names at minimum tofu init tofu plan tofu apply ``` This creates the private network + subnet, one server + scoped firewall per host (labeled `role = dev/prod/vpn`), and one DNS zone per domain in `var.dns_zones` plus every record in [Service inventory](#service-inventory). `terraform.tfvars` and any `*.tfvars` file are gitignored — never commit real values there. Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`, `tofu output dns_nameservers`. ### Managing DNS Creating a zone doesn't make Hetzner authoritative by itself — run `tofu output dns_nameservers` and set those as the domain's NS records at its registrar. Propagation time depends on the registrar and the domain's previous NS TTL. To add a subdomain, add an entry to `local.dns_records` in `infra/main.tf` (zone, name, target host) and re-run `tofu apply` — don't hand-create records in the Hetzner console, they'll drift from state. `var.records` supports `MX`/`TXT`/`CNAME`/etc. too (see `infra/modules/dns/variables.tf` for the value format each type expects); entries sharing zone/name/type merge into one RRSet. `local.mail_records` tracks the Microsoft 365 records for `luke-else.co.uk` (MX, SPF, Google-site-verification, `autodiscover`). DMARC and DKIM aren't set up yet — see [Caveats](#caveats). ## Bootstrapping and deploying (`ansible/`) Once `tofu apply` has created the servers, Ansible installs Docker, creates the `deploy` user, hardens SSH, copies `services//` to each server, and runs `spinup.sh`/`spindown.sh`. Full detail in [`ansible/README.md`](ansible/README.md); the short version: ```sh cd ansible ansible-galaxy collection install -r requirements.yml export HCLOUD_TOKEN=your-hetzner-api-token # never commit this # vpn first - its firewall accepts SSH from var.allowed_ssh_source_ips directly ansible-playbook playbooks/site.yml -l role_vpn # SSH to vpn as deploy and connect to the WireGuard tunnel it just started, then: ansible-playbook playbooks/site.yml -l role_dev,role_prod ``` `dev`/`prod`'s firewalls only accept SSH from `vpn`'s own public IP, so on a from-scratch estate they aren't reachable until `vpn` is up and you're tunneled through it — visit `https://vpn.luke-else.co.uk`, complete wg-easy's first-run setup, add a client peer, and connect before running the second command. If it's run before you're connected, Ansible just fails to connect — re-run once connected. ### Persistent data Every stateful service mounts a named Docker volume (`gitea_data`, `bitwarden_data`, `letsencrypt_data`, etc.) rather than a Hetzner volume. Each host's `backup-docker-compose.yml` defines: - **`backup`** — [`offen/docker-volume-backup`](https://github.com/offen/docker-volume-backup), nightly cron, tars every mounted volume to `s3://$AWS_S3_BUCKET_NAME/$AWS_S3_PATH/-.tar.gz`, 14 days of retention. Containers that need a consistent snapshot are stopped for the duration and restarted after. - **`restore`** — a one-shot container `spinup.sh` runs before anything else starts. Any volume that's already empty is populated from the most recent backup; anything with data is left untouched. On a fresh server (or no prior backup), it's a no-op. The S3 credentials (`AWS_S3_BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, etc.) are rendered into `services//.env` by Ansible from the `BACKUP_S3_*` env vars — `spinup.sh` refuses to start anything if `.env` is missing. To trigger either manually: ```sh cd services/ docker compose -f backup-docker-compose.yml exec backup backup # force an immediate backup docker compose -f backup-docker-compose.yml run --rm restore # restore any currently-empty volumes ``` ## Running the services (`services/`) After `ansible-playbook playbooks/deploy.yml` has copied `services//` to the server, SSH in as `deploy` and run the matching script — or just use `ansible-playbook playbooks/spinup.yml` / `spindown.yml`, which do this remotely: ```sh # on dev ./spinup.sh # Traefik → Gitea → (waits, generates a runner token) → Runners → Watchtower ./spindown.sh # reverse order, then prunes images/volumes # on prod ./spinup.sh # Traefik → Watchtower → status → websites → Bitwarden → RustDesk ./spindown.sh # on vpn ./spinup.sh # Traefik → wg-easy → Watchtower ./spindown.sh ``` Each stack can also be managed individually with plain Compose, e.g. `docker compose -f bitwarden-docker-compose.yml up -d` from inside `services/prod`. All three hosts run [Watchtower](https://containrrr.dev/watchtower/), polling every 60s with cleanup enabled, so images stay current automatically once deployed — re-run the spinup scripts only after adding/removing a service or changing compose files. Every public-facing service is fronted by its host's own Traefik, terminating TLS via Let's Encrypt, joining an external `proxy` network and opting in via `traefik.enable=true` labels (RustDesk and the Gitea SSH port publish ports directly, since they aren't HTTP). `vpn` runs [wg-easy](https://github.com/wg-easy/wg-easy) — WireGuard plus a web UI, fronted by Traefik. It has no automated first-run setup: visit `https://vpn.luke-else.co.uk`, pick an admin username/password (enable 2FA — this account can view/rotate every client's private key and is reachable from the public internet), set the connect host/port (`vpn.luke-else.co.uk` / `51820`), then add a client per device and download its config or scan its QR code. wg-easy's own state lives in the `wg_easy_data` volume, backed up like everything else; client configs themselves are never stored anywhere but the UI. ## Service inventory ### `dev` | Service | Compose file | Domain(s) | |---|---|---| | Traefik | `traefik-docker-compose.yml` | `traefik.cicd.luke-else.co.uk` | | Gitea | `gitea-docker-compose.yml` | `git.luke-else.co.uk` (HTTP), SSH on `222` | | Gitea Actions runner(s) | `Runners/docker-compose.yml` (generated, 3 by default) | N/A | | Watchtower | `watchtower-docker-compose.yml` | — | | Backup/restore | `backup-docker-compose.yml` | — | ### `prod` | Service | Compose file | Domain(s) | |---|---|---| | Traefik | `traefik-docker-compose.yml` | `traefik.luke-else.co.uk` | | Websites | `web-docker-compose.yml` | `luke-else.co.uk`, `dev.luke-else.co.uk`, `metarius.luke-else.co.uk`, `www.divine-couture.co.uk`, `snexo.co.uk` | | Status page (Uptime Kuma) | `status-docker-compose.yml` | `status.luke-else.co.uk` | | Bitwarden (Vaultwarden) | `bitwarden-docker-compose.yml` | `bitwarden.luke-else.co.uk` | | RustDesk relay (hbbs/hbbr) | `rd-docker-compose.yml` | `rd.luke-else.co.uk`, ports `21115-21119` | | Watchtower | `watchtower-docker-compose.yml` | — | | Backup/restore | `backup-docker-compose.yml` | — | ### `vpn` | Service | Compose file | Domain(s) | |---|---|---| | Traefik | `traefik-docker-compose.yml` | `traefik.vpn.luke-else.co.uk` | | WireGuard (wg-easy) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `51820` | | Watchtower | `watchtower-docker-compose.yml` | — | | Backup/restore | `backup-docker-compose.yml` | — | ## Development container `.devcontainer` is a git submodule providing a ready-to-use OpenTofu development environment (VS Code + OpenTofu/Docker/Mermaid extensions): ```sh git submodule update --init --recursive ``` Then reopen the repo in VS Code with the Dev Containers extension. ## Caveats - **UFW isn't configured** — the Hetzner Cloud Firewalls already allowlist per-host ports, but there's no host-level firewall as defense in depth yet. - **DMARC and DKIM aren't set up** for `luke-else.co.uk`. DKIM's targets are generated per-tenant by Microsoft 365 (Defender portal → Email & collaboration → Policies & rules → DKIM) and can't be guessed — enable it there first, then add the two CNAMEs it gives you to `local.mail_records`. - **Only IPv4 DNS is managed.** None of the infra modules track servers' IPv6 addresses, so no AAAA records are generated even though the firewalls already allow IPv6 traffic. - **`infra/modules/dns/moved.tf`** re-points pre-existing A records at their new resource address after the DNS module was generalized for non-A record types. It's a one-time migration aid — safe to delete once `tofu plan` shows no unexpected changes. ## Security notes - Real secrets (`HCLOUD_TOKEN`, `BACKUP_S3_*`, `*.tfvars`, your SSH private key) must never be committed — see `.gitignore`. S3 credentials land in each host's `services//.env` (mode `0600`, gitignored, rendered by Ansible). - SSH to `dev`/`prod` is restricted to `vpn`'s own public IP — you must be tunneled into the VPN to reach them. SSH to `vpn` itself is gated by `var.allowed_ssh_source_ips`; narrow this from the default `0.0.0.0/0` once you know your egress IP(s). - Firewalls are per-host allowlists (`infra/modules//main.tf`) — only ports actually used by that host's compose stacks are open. - Ansible's bootstrap role disables SSH password auth, restricts root login to key-only, and creates a separate sudo user (`deploy_user`) for day-to-day access. - DNS zones carry both Hetzner's `delete_protection` and Terraform's `prevent_destroy` — losing a zone takes every record in it with it, across all three managed domains.