# 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/), each running a set of Docker Compose stacks behind [Traefik](https://traefik.io/traefik/).

## Contents - [Architecture](#architecture) - [Repository layout](#repository-layout) - [Prerequisites](#prerequisites) - [Provisioning the infrastructure](#provisioning-the-infrastructure-infra) - [Deploying the services](#deploying-the-services-services) - [Service inventory](#service-inventory) - [First-time setup](#first-time-setup) - [Development container](#development-container) - [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 disk1(mdi:disk)[Storage] in cloud service disk2(mdi:disk)[Storage] 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 dev:L -- R:prod disk1:B -- T:prod disk2:B -- T:dev prod:B -- T:prodfirewall vpn:B -- T:vpnfirewall prodfirewall: L -- R: gateway vpnfirewall: B -- T: gateway ``` `gateway` represents the public internet, not a provisioned resource. | Server | Purpose | Network | Volume | |---|---|---|---| | `dev` | Gitea, CI runner, dev-facing Traefik | Private network only (no public firewall exposure beyond CI/CD) | `dev-storage` | | `prod` | Public-facing websites, Bitwarden, RustDesk, status page, prod Traefik | Private network + public firewall | `prod-storage` | | `vpn` | OpenVPN + its own Traefik | **Not** attached to the private network — kept isolated so a compromised VPN endpoint can't pivot to `dev`/`prod` | none | `dev` and `prod` share a private Hetzner network (`10.0.1.0/24` by default) so CI/CD on `dev` can reach deployment targets on `prod` without exposing that traffic publicly. `vpn` is deliberately kept off this network. Each server has its own Hetzner Cloud Firewall (see `infra/modules//main.tf`) that only opens the ports actually used by the compose stacks running on it, plus SSH restricted to `var.allowed_ssh_source_ips`. ## Repository layout ``` . ├── infra/ # OpenTofu (Terraform-compatible) config — provisions the 3 servers, network, firewalls, volumes │ ├── main.tf # root module: wires network + dev/prod/vpn modules together │ ├── variables.tf # shared inputs (sizes, locations, IP ranges, SSH key names) │ ├── outputs.tf # pass-through outputs from each host module │ ├── ssh.tf # looks up each SSH key already uploaded to Hetzner Cloud │ ├── versions.tf # provider requirements │ ├── terraform.tfvars.example │ ├── scripts/ │ │ └── bootstrap.sh.tftpl # post-install script run on every host right after creation │ └── modules/ │ ├── network/ # shared private network + subnet (used by dev and prod) │ ├── dev/ # dev server + firewall + volume + renders services/dev/Runners/docker-compose.yml │ │ └── templates/ │ │ └── runners-docker-compose.yml.tftpl # shape of each generated runner service │ ├── prod/ # prod server + firewall + volume │ ├── vpn/ # vpn server + firewall (no private network, no volume) │ └── dns/ # Hetzner DNS zones + A records for every domain in var.dns_zones ├── services/ # Docker Compose stacks, grouped by which server they run on │ ├── dev/ # Gitea + CI runner + Traefik (git.luke-else.co.uk, cicd.luke-else.co.uk) │ ├── prod/ # Websites, Bitwarden, RustDesk, status page + Traefik │ ├── vpn/ # OpenVPN + Traefik │ └── todo.md # Outstanding manual setup/hardening tasks ├── docs/ │ └── architecture.md # Source of the architecture diagram above ├── .devcontainer/ # Git submodule: shared devcontainer for working on 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 logical service, plus a `spinup.sh` / `spindown.sh` pair that brings up or tears down every stack on that host in the right order. `infra/modules/` mirrors this same dev/prod/vpn split on the provisioning side, so a given host's cloud resources and its compose stacks are easy to find side by side. ## 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) - all are installed on every server - plus the private key matching one of them available locally (OpenTofu uses it once per server to run the post-install bootstrap — see below) - [OpenTofu](https://opentofu.org/docs/intro/install/) `>= 1.6.0` - Ownership of the domains in `var.dns_zones` at whatever registrar they're bought through, so you can point their NS records at Hetzner (see [Managing DNS](#managing-dns) — the zones and records themselves are created for you) Docker + the Compose plugin no longer need installing by hand — the bootstrap script below handles that. ## 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 and ssh_private_key_path at minimum tofu init tofu plan tofu apply ``` This creates, via `module.network` / `module.dev` / `module.prod` / `module.vpn` / `module.dns` in `infra/main.tf`: - `hcloud_network` + subnet, shared by `dev` and `prod` (`modules/network`) - one `hcloud_server` + `hcloud_firewall` per host, scoped to the ports each host actually uses (`modules/dev`, `modules/prod`, `modules/vpn`) - one `hcloud_volume` each for `dev` and `prod` (`modules/dev`, `modules/prod`; `vpn` has none) - one Hetzner DNS zone per domain in `var.dns_zones`, plus every A record in [Service inventory](#service-inventory) (`modules/dns` — see [Managing DNS](#managing-dns)) Useful outputs: `tofu output dev_ipv4`, `tofu output prod_ipv4`, `tofu output vpn_ipv4`, `tofu output dns_nameservers`. `terraform.tfvars` and any `*.tfvars` file are gitignored — never commit real values there. Defaults for server sizes, locations, and IP ranges live in `infra/variables.tf` and are passed down into the modules from `infra/main.tf`; override them per-environment via `terraform.tfvars`. ### Post-install bootstrap Immediately after each `hcloud_server` is created, OpenTofu uploads and runs [`infra/scripts/bootstrap.sh.tftpl`](infra/scripts/bootstrap.sh.tftpl) over SSH as `root` (`connection` + `file`/`remote-exec` provisioners in each `modules//main.tf`). It: - installs Docker Engine + the Compose plugin - creates a non-root sudo user (`var.deploy_user`, default `deploy`) with the same SSH keys as root, in the `sudo` and `docker` groups - hardens `sshd`: password authentication off, root login restricted to key-only (`PermitRootLogin prohibit-password`) - installs and enables `unattended-upgrades` for automatic security patches After bootstrap, SSH in as `var.deploy_user` for day-to-day work — root key-based login still works as a fallback. ### Scaling Gitea Actions runners The number of Gitea Actions runner containers on `dev` is an OpenTofu input, `var.dev_runner_count` (default `3`). On every `tofu apply`, `modules/dev`'s `local_file.runners_compose` resource renders [`modules/dev/templates/runners-docker-compose.yml.tftpl`](infra/modules/dev/templates/runners-docker-compose.yml.tftpl) straight into [`services/dev/Runners/docker-compose.yml`](services/dev/Runners/docker-compose.yml) in your working tree — one `runner-N` service per count, each with its own container name and `/data` volume so their registrations don't collide. That file is generated: change `dev_runner_count` in `terraform.tfvars` and re-run `tofu apply` rather than hand-editing it. This only updates your local working tree — `tofu apply` doesn't reach out and start containers on `dev` itself. Redeploy as usual (re-copy `services/dev/` to the server and re-run `spinup.sh`) to apply a count change. Registration tokens are handled automatically, not baked into the generated file: `services/dev/spinup.sh` waits for Gitea to come up, runs `gitea actions generate-runner-token` inside the Gitea container, and writes the result to `Runners/.env`, which Compose loads automatically. There's no manual admin-UI step for this anymore. ### First apply: bootstrapping order matters `dev` and `prod`'s firewalls only accept SSH from `vpn`'s public IP (see [Security notes](#security-notes)), but `vpn`'s own OpenVPN service isn't running until you deploy it — so on a from-scratch `tofu apply`, the `dev`/`prod` bootstrap provisioners can't connect yet. Bring the estate up in this order: 1. `tofu apply -target=module.vpn` — creates `vpn` only; its firewall still allows SSH from `var.allowed_ssh_source_ips`, so its own bootstrap provisioner runs fine. 2. Deploy and start the VPN service on `vpn` (see [Deploying the services](#deploying-the-services-services)), then connect to it with an OpenVPN client. 3. `tofu apply` — creates `network`, `dev`, `prod`. Now that your machine is tunneled through `vpn`, its NATed egress IP matches the firewall rule and the `dev`/`prod` bootstrap provisioners can connect. If step 3 is run before you're connected to the VPN, the `dev`/`prod` provisioners will fail and Terraform will mark those servers tainted — re-running `tofu apply` once connected will recreate and re-bootstrap them. ### Managing DNS `module.dns` (in `infra/modules/dns`) creates one Hetzner DNS zone per domain in `var.dns_zones` (default: `luke-else.co.uk`, `divine-couture.co.uk`, `snexo.co.uk`) and an `hcloud_zone_rrset` A record for every hostname currently referenced by a Traefik `Host()` rule anywhere under `services/` — kept in sync with `local.dns_records` in `infra/main.tf`, pointed at whichever of `dev`/`prod`/`vpn` actually serves it. Zones have `prevent_destroy = true`, matching the volumes — losing one deletes every record in it. Creating the zone doesn't make Hetzner authoritative for the domain by itself: you still need to point that domain's NS records at Hetzner's nameservers at whichever registrar it's registered through. Run `tofu output dns_nameservers` after applying to get the exact nameservers per domain, and set those as the domain's NS records at the registrar. Propagation can take a while depending on the registrar and the domain's previous NS TTL. Adding a new subdomain: add an entry to `local.dns_records` in `infra/main.tf` (zone, name, and the target `module..ipv4`) and re-run `tofu apply` — don't hand-create records in the Hetzner console, they'll drift from state. Note `name = "@"` is Hetzner's convention for a zone's apex record (e.g. bare `snexo.co.uk`), not an empty string. Only A records for IPv4 are managed here — none of the modules currently track servers' IPv6 addresses, so AAAA records aren't generated even though the firewalls already allow IPv6 traffic. ## Deploying the services (`services/`) Once a server exists, copy the relevant `services//` directory to it (e.g. `scp -r services/prod user@:~/services`) and run the matching script from inside that directory: ```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 → OpenVPN → Watchtower ./spindown.sh ``` Each stack can also be managed individually with plain Compose, e.g.: ```sh cd services/prod docker compose -f bitwarden-docker-compose.yml up -d docker compose -f bitwarden-docker-compose.yml down ``` All three hosts run [Watchtower](https://containrrr.dev/watchtower/) polling every 60s with cleanup enabled, so images are kept current automatically once deployed — the spinup scripts only need to be re-run after adding/removing a service or changing compose files. Every public-facing service is fronted by its host's own Traefik instance, terminating TLS via Let's Encrypt (`tlschallenge`, port 80/443). Each stack joins an external `proxy` Docker network and opts in via `traefik.enable=true` labels rather than publishing ports directly (RustDesk and the Gitea SSH port are the deliberate exceptions, since they aren't HTTP). ## 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 — see [Scaling Gitea Actions runners](#scaling-gitea-actions-runners)) | `cicd.luke-else.co.uk` | | Watchtower | `watchtower-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` | — | ### `vpn` | Service | Compose file | Domain(s) | |---|---|---| | Traefik | `traefik-docker-compose.yml` | `traefik.vpn.luke-else.co.uk` | | OpenVPN (Dockovpn) | `vpn-docker-compose.yml` | `vpn.luke-else.co.uk`, UDP `1194` | | Watchtower | `watchtower-docker-compose.yml` | — | ## First-time setup A few things need manual attention before a stack is fully live — tracked in [`services/todo.md`](services/todo.md), summarized here: - **Traefik dashboard auth**: the committed basic-auth hash is a placeholder. Generate your own with `echo $(htpasswd -nb user password) | sed -e 's/\$/\$\$/g'` and replace the `traefik-auth` middleware value in each `traefik-docker-compose.yml`. - **General host hardening**: non-root user, Docker, and unattended-upgrades are now handled automatically by the [post-install bootstrap](#post-install-bootstrap); UFW is the remaining manual item in `services/todo.md` (the Hetzner Cloud Firewalls already allowlist per-host ports — see [Security notes](#security-notes)). ## Development container `.devcontainer` is a git submodule providing a ready-to-use OpenTofu development environment (VS Code + OpenTofu/Docker/Mermaid extensions). After cloning: ```sh git submodule update --init --recursive ``` Then reopen the repo in VS Code with the Dev Containers extension. ## Security notes - Real secrets (`HCLOUD_TOKEN`, `*.tfvars`, your SSH private key) must never be committed — see `.gitignore`. - SSH to `dev` and `prod` is restricted to the `vpn` server's own public IP — you must be tunneled into the VPN to reach them over SSH. 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 own egress IP(s). - `vpn` is intentionally excluded from the private network so that a compromised VPN endpoint cannot reach `dev` or `prod` directly over it — SSH access still works because the VPN's egress traffic is NATed through its own public IP. - Firewalls are allowlists scoped per host in `infra/modules//main.tf` — only ports actually used by that host's compose stacks (plus the cross-host private network range) are open. - Every host's [post-install bootstrap](#post-install-bootstrap) disables SSH password authentication, restricts root login to key-only, and creates a separate sudo user (`var.deploy_user`) for day-to-day access. - DNS zones (`modules/dns`) carry both Hetzner's own `delete_protection` and Terraform's `prevent_destroy` — losing a zone takes every record in it with it, including for `divine-couture.co.uk` and `snexo.co.uk`, not just `luke-else.co.uk`.