From c6f50bcb50c17ee44ec2a356f33b3a8cc8fa8172 Mon Sep 17 00:00:00 2001 From: Maximus Gorog Date: Sat, 23 May 2026 22:52:43 -0600 Subject: [PATCH] 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. --- DEPLOY.md | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 228 insertions(+) diff --git a/DEPLOY.md b/DEPLOY.md index 4980268..ced470d 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1,5 +1,18 @@ # 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) @@ -111,3 +124,218 @@ 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 ~1–2 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 +``` + +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.