Files
server/readme.md
T

9.3 KiB

Server

Infrastructure-as-code and service definitions for luke-else.co.uk's self-hosted server estate: three Hetzner Cloud VPS instances provisioned with OpenTofu, each running a set of Docker Compose stacks behind Traefik.

Contents

Architecture

Three servers, one shared private network:

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/firewalls.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
│   ├── servers.tf
│   ├── network.tf
│   ├── firewalls.tf
│   ├── volumes.tf
│   ├── ssh.tf
│   ├── variables.tf
│   ├── outputs.tf
│   ├── versions.tf
│   └── terraform.tfvars.example
├── 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.

Prerequisites

  • A Hetzner Cloud project and API token
  • An SSH key uploaded to that project (Console → Security → SSH Keys)
  • OpenTofu >= 1.6.0
  • Docker + the Compose plugin on each target server
  • DNS records for the domains in Service inventory pointed at the relevant server's public IP

Provisioning the infrastructure (infra/)

cd infra
export HCLOUD_TOKEN=your-hetzner-api-token   # never commit this
cp terraform.tfvars.example terraform.tfvars
$EDITOR terraform.tfvars                     # set ssh_key_name at minimum

tofu init
tofu plan
tofu apply

This creates:

  • hcloud_server.dev, hcloud_server.prod, hcloud_server.vpn
  • hcloud_network.main + subnet, shared by dev and prod
  • hcloud_firewall.dev / .prod / .vpn, scoped to the ports each host actually uses
  • hcloud_volume.dev_storage / .prod_storage

Useful outputs: tofu output dev_ipv4, tofu output prod_ipv4, tofu output vpn_ipv4.

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 can be overridden per-environment via terraform.tfvars.

Deploying the services (services/)

Once a server exists, copy the relevant services/<host>/ directory to it (e.g. scp -r services/prod user@<prod_ipv4>:~/services) and run the matching script from inside that directory:

# on dev
./spinup.sh     # Traefik → Gitea Runner → Watchtower
./spindown.sh   # reverse order, then prunes images/volumes

# on prod
./spinup.sh    # Traefik, then (after a 20s cert/registry settle) 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.:

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 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 Runners/docker-compose.yml 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, summarized here:

  • Gitea Actions runner: set a real GITEA_RUNNER_REGISTRATION_TOKEN in services/dev/Runners/docker-compose.yml (generate one from the Gitea admin UI) before starting the runner.
  • 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, UFW, unattended-upgrades, Docker install — see services/todo.md.

Development container

.devcontainer is a git submodule providing a ready-to-use OpenTofu development environment (VS Code + OpenTofu/Docker/Mermaid extensions). After cloning:

git submodule update --init --recursive

Then reopen the repo in VS Code with the Dev Containers extension.

Security notes

  • Real secrets (HCLOUD_TOKEN, *.tfvars, .env files) must never be committed — see .gitignore.
  • SSH is restricted to var.allowed_ssh_source_ips on every host; 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.
  • Firewalls are allowlists scoped per host in infra/firewalls.tf — only ports actually used by that host's compose stacks (plus the cross-host private network range) are open.