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

11 KiB
Raw Permalink Blame History

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 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)

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:

curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER     # log out + back in

2. Get the code on the box

git clone https://maxgit.wg/max/terainia.git  # or wherever you push it
cd terainia

3. Build + run

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:

# 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:
# 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

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-caddycaddy: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:

# 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):

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:

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)

[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:

# 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 / expireddocker 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.