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.
This commit is contained in:
Maximus Gorog 2026-05-23 22:52:43 -06:00
parent e4cf5a9bed
commit c6f50bcb50

228
DEPLOY.md
View file

@ -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 ~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.