Files
server/readme.md
T

207 lines
9.3 KiB
Markdown

# 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/).
<p align="center">
<img src="assets/images/main.png" width="70%">
</p>
## 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/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](https://console.hetzner.cloud/) project and API token
- An SSH key uploaded to that project (Console → Security → SSH Keys)
- [OpenTofu](https://opentofu.org/docs/intro/install/) `>= 1.6.0`
- Docker + the Compose plugin on each target server
- DNS records for the domains in [Service inventory](#service-inventory) pointed at the relevant server's public IP
## 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_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:
```sh
# 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.:
```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 | `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`](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:
```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`, `.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.