# 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 ~5–8 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` → ``, 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 ~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.