terainia/DEPLOY.md
Maximus Gorog c6f50bcb50 DEPLOY.md: add active-deployment runbook for voxel.mxvs.art
Records the live production state (Linode IP, paths, redeploy command,
firewall + fail2ban + SSH hardening, TLS via Caddy, DNS via Namecheap
Advanced DNS, rollback steps, troubleshooting checklist) so a fresh
session can pick this up without re-deriving any of it.
2026-05-23 22:52:43 -06:00

341 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Deploying the voxel game
> **Active production deployment** as of `alpha-0.0.2`:
> - **URL:** https://voxel.mxvs.art
> - **Host:** Linode Nanode 1GB at `45.79.220.199` (Ubuntu 24.04 LTS)
> - **Repo:** https://maxgit.wg/max/terainia (Forgejo on wireguard)
> - **DNS:** `voxel.mxvs.art` A record in Namecheap Advanced DNS →
> `45.79.220.199` (NOT Cloudflare-proxied; Caddy on the box handles TLS)
> - **TLS:** Let's Encrypt via Caddy (auto-renew, ~60-day cycle)
> - **Deploy path on host:** `/opt/voxel-game/`
>
> Skip to [Active deployment runbook](#active-deployment-runbook) for the
> ops cheatsheet for *this* deployment. The sections below are general
> reference for setting up *another* host from scratch.
Three layers, pick the combination that fits.
## Local-only (development)
```sh
docker compose up --build
```
Serves on `http://localhost:8080`. `127.0.0.1`-bound — not reachable from
the network. Good for iterating on Docker without exposing anything.
## Anywhere with a public IP (the real deploy)
This assumes a cheap VPS with Docker + docker-compose-plugin installed.
Tested targets: Hetzner CPX11 (~$5/mo), DigitalOcean Basic Droplet ($4),
Vultr (~$2.50), Oracle Cloud Always Free tier (ARM Ampere instance — free
forever, just slow to provision).
### 1. SSH in and install Docker
Debian/Ubuntu host:
```sh
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # log out + back in
```
### 2. Get the code on the box
```sh
git clone https://maxgit.wg/max/terainia.git # or wherever you push it
cd terainia
```
### 3. Build + run
```sh
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
The container binds to `0.0.0.0:8080`. The first build takes ~58 min on
a small VPS because it compiles Rust + the wasm client; subsequent
builds are fast due to layer caching.
### 4. Put TLS in front
You have two clean options here.
**Option A — Cloudflare proxy (no cert on the VPS, simplest).**
- In your Cloudflare dashboard for `mxvs.art`, add an A record:
`voxel``<VPS public IP>`, proxy status **Proxied** (orange cloud).
- Cloudflare → SSL/TLS → set encryption mode to **Flexible**
(Cloudflare terminates HTTPS, talks HTTP to the VPS).
- That's it. `https://voxel.mxvs.art` serves the game.
**Option B — Caddy on the VPS for Let's Encrypt.**
If you want real end-to-end TLS instead of Cloudflare-terminated:
```yaml
# docker-compose.prod.yml addition
caddy:
image: caddy:2-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
```
```caddy
# Caddyfile
voxel.mxvs.art {
reverse_proxy voxel:8080
}
```
Then keep `voxel` bound to `127.0.0.1:8080` (or use Compose's internal
network only — drop the `ports:` mapping on the `voxel` service and let
Caddy reach it via the service name). The DNS A record in Cloudflare
should be **DNS only** (grey cloud) in this case so Cloudflare doesn't
re-terminate TLS.
### 5. Deploy updates
```sh
git pull
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```
Down-and-up time is a couple of seconds while the new container
swaps in.
## Local + Cloudflare Tunnel (no VPS)
If you don't want a VPS yet, the game can run on your machine and be
exposed through a Cloudflare named tunnel pointing at `voxel.mxvs.art`.
The Dockerfile is still useful for keeping the local environment
consistent — just `docker compose up` and then point a `cloudflared`
container at `host.docker.internal:8080`. See the Cloudflare Tunnel
docs for the named-tunnel setup; the credential file (`cert.pem` from
`cloudflared tunnel login`) needs to land at `~/.cloudflared/cert.pem`
on whichever host the tunnel runs on.
---
# Active deployment runbook
Everything below is the *actual* live config for `voxel.mxvs.art` as of
`alpha-0.0.2`. Refer back here when redeploying, debugging, or
rebuilding from scratch.
## Coordinates
| What | Where |
|---|---|
| Live URL | https://voxel.mxvs.art |
| Hosting | Linode Nanode 1GB (Akamai), 1 vCPU / 1 GB RAM / 25 GB SSD |
| IPv4 | `45.79.220.199` |
| OS | Ubuntu 24.04 LTS (Noble) |
| SSH | `ssh -i ~/.ssh/id_rsa root@45.79.220.199` (key-only, no password auth) |
| App dir | `/opt/voxel-game/` |
| Forgejo repo | https://maxgit.wg/max/terainia (private, wireguard-only) |
| Forgejo API token | `8a169ef2b82d700ea4deecc2163ebb8655fd40bd` (used for `git push` over HTTPS) |
| DNS | Namecheap, Advanced DNS tab. `voxel` A record → `45.79.220.199`, TTL Automatic. Nameservers stay Namecheap BasicDNS (NOT Cloudflare). |
## Stack on the host
Two Docker containers managed by Compose:
- **`voxel-game`** — the Rust wasm+server image (Multi-stage Dockerfile,
`rust:1-bookworm` builder → `debian:bookworm-slim` runtime, non-root
uid 10001). Listens on `0.0.0.0:8080` *inside* the container; not
published to the host in production.
- **`voxel-caddy`** — `caddy:2-alpine`. Binds host `0.0.0.0:80` and
`0.0.0.0:443`. Reverse-proxies `voxel.mxvs.art` to `voxel:8080` over
the internal Docker network. Auto-issues + renews the Let's Encrypt
cert (stored in the `caddy_data` volume).
## Redeploy after a code change (the 30-second loop)
From this dev machine, with the repo at `/home/maximus/.env/web/voxel-game`:
```sh
# 1. Sync code to the box (excludes target/, .git/, generated wasm).
rsync -avz --delete \
--exclude='target/' --exclude='**/target/' --exclude='.git/' \
--exclude='web/voxel_game.js' --exclude='web/voxel_game_bg.wasm' \
--exclude='web/voxel_game.d.ts' \
-e "ssh -i $HOME/.ssh/id_rsa" \
/home/maximus/.env/web/voxel-game/ root@45.79.220.199:/opt/voxel-game/
# 2. Rebuild + restart on the box.
ssh -i ~/.ssh/id_rsa root@45.79.220.199 \
'cd /opt/voxel-game && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build'
# 3. Smoke test.
curl -sS -o /dev/null -w "%{http_code} %{size_download}\n" https://voxel.mxvs.art/
```
First build was ~8 min (full Rust + wasm-bindgen-cli compile). Cached
rebuilds with source-only changes are ~12 min.
> **Forgejo is on `maxgit.wg` (wireguard), which the Linode does not
> reach.** The rsync path above is the canonical deploy mechanism; the
> Linode does not `git pull`. Push tags + commits to Forgejo for
> archival/source-of-truth, deploy via rsync.
## Pushing to Forgejo from this dev box
The repo's HTTPS remote needs the token as a header (Forgejo doesn't
have a SSH key set up here):
```sh
git -c http.extraHeader="Authorization: token 8a169ef2b82d700ea4deecc2163ebb8655fd40bd" \
push origin main
git -c http.extraHeader="Authorization: token 8a169ef2b82d700ea4deecc2163ebb8655fd40bd" \
push origin <tag-name>
```
Or stash the header globally for the repo:
```sh
git config --local http.extraHeader "Authorization: token 8a169ef2b82d700ea4deecc2163ebb8655fd40bd"
git push origin main # no header flag needed afterwards
```
## Hardening — what's installed on the box
All set up during initial provision; survives reboots; nothing to do
after a redeploy.
### Packages
```
docker-ce + docker-compose-plugin # container runtime
iptables-persistent + netfilter-persistent # firewall rules survive reboot
fail2ban # ban brute-force SSH attempts
unattended-upgrades # auto security patches
rsync # deploy transport
```
### Firewall (`/root/firewall.sh`)
Idempotent script; only touches the INPUT chain so Docker's
FORWARD/DOCKER chains keep working. Default policy stays ACCEPT but the
last rule in INPUT is DROP — same effect, safer to layer.
Rules (IPv4 + IPv6 parity):
| Port / Source | Action |
|---|---|
| `lo` | ACCEPT |
| ESTABLISHED, RELATED | ACCEPT |
| ICMP echo-request (rate-limit 5/s) | ACCEPT |
| TCP 22 NEW (rate-limit 6/min, burst 12) | ACCEPT |
| TCP 80 | ACCEPT |
| TCP 443 | ACCEPT |
| TCP 8080 | ACCEPT (defense-in-depth; container only listens on internal Docker net in prod) |
| everything else | LOG (5/min) → DROP |
Re-apply if needed: `bash /root/firewall.sh` (re-saves to `/etc/iptables/rules.v4` and `rules.v6` via `netfilter-persistent save`).
### fail2ban (`/etc/fail2ban/jail.local`)
```ini
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
[sshd]
enabled = true
port = 22
```
Status: `fail2ban-client status sshd`.
### SSH hardening (`/etc/ssh/sshd_config`)
```
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
```
Public key in `/root/.ssh/authorized_keys` is `~/.ssh/id_rsa.pub` from
this dev machine (`max@DESKTOP-TB4UFSM`).
### Unattended upgrades
Enabled with `dpkg-reconfigure unattended-upgrades`. Service:
`unattended-upgrades` (active, enabled at boot).
## TLS / Caddy notes
- Caddy config: `/opt/voxel-game/Caddyfile` (mounted read-only into
container). Single block: `voxel.mxvs.art { encode zstd gzip;
reverse_proxy voxel:8080 }` plus a global `email mgorog@gmail.com`
for ACME.
- Cert + ACME account live in the `voxel-game_caddy_data` Docker volume;
`caddy_config` for the autosave Caddyfile. Don't delete these volumes
or Caddy will re-request a cert (Let's Encrypt rate limit: 5 dupes
per week).
- Renewal is automatic ~30 days before expiry.
## DNS — the bit that's NOT Cloudflare
The domain `mxvs.art` is registered with Namecheap. We kept Namecheap's
own DNS (BasicDNS) rather than delegating nameservers to Cloudflare.
The `voxel` subdomain is configured under **Domain List → Manage
mxvs.art → Advanced DNS**:
| Type | Host | Value | TTL |
|---|---|---|---|
| A Record | `voxel` | `45.79.220.199` | Automatic |
Default placeholder records (the `www → parkingpage.namecheap.com`
CNAME and `@ → URL Redirect to www.mxvs.art`) were deleted because they
collide with the parking page and weren't relevant.
## Rolling back
Tagged releases live in Forgejo. To deploy a previous tag instead of
the current `main`:
```sh
# Local: check out the tag
git checkout alpha-0.0.2
# Rsync (deletes files added since the tag — that's intentional)
rsync -avz --delete ... root@45.79.220.199:/opt/voxel-game/
# Rebuild on the box
ssh -i ~/.ssh/id_rsa root@45.79.220.199 \
'cd /opt/voxel-game && docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build'
```
Container state is ephemeral except for the Caddy cert volume — there's
no game-state persistence to roll back, so this is safe.
## Troubleshooting checklist
- **Site returns 502 / connection refused** → check `docker ps` on the
box; `docker logs voxel-game` for panics; `docker logs voxel-caddy`
for upstream errors.
- **TLS cert won't issue / expired** → `docker logs voxel-caddy` for
ACME errors. Verify port 80 is reachable from the internet (Let's
Encrypt validates via tls-alpn-01 on 443 *and* http-01 on 80).
- **Locked out via SSH** → use Linode's LISH web console
(Dashboard → instance → Launch LISH Console), log in via password
reset if needed, fix `/root/.ssh/authorized_keys` or the firewall.
- **iptables broke connectivity** → LISH in, run `iptables -F INPUT`
to flush, then re-apply `bash /root/firewall.sh`.
- **Build fails: "rustc X.Y not supported"** → `wasm-bindgen-cli`
bumped its MSRV. The Dockerfile currently uses `rust:1-bookworm`
(latest stable). Pin in `Dockerfile` if needed.