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.
11 KiB
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.artA 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 ~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→<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.artserves 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-bookwormbuilder →debian:bookworm-slimruntime, non-root uid 10001). Listens on0.0.0.0:8080inside the container; not published to the host in production.voxel-caddy—caddy:2-alpine. Binds host0.0.0.0:80and0.0.0.0:443. Reverse-proxiesvoxel.mxvs.arttovoxel:8080over the internal Docker network. Auto-issues + renews the Let's Encrypt cert (stored in thecaddy_datavolume).
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 ~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 notgit 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 globalemail mgorog@gmail.comfor ACME. - Cert + ACME account live in the
voxel-game_caddy_dataDocker volume;caddy_configfor 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 pson the box;docker logs voxel-gamefor panics;docker logs voxel-caddyfor upstream errors. - TLS cert won't issue / expired →
docker logs voxel-caddyfor 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_keysor the firewall. - iptables broke connectivity → LISH in, run
iptables -F INPUTto flush, then re-applybash /root/firewall.sh. - Build fails: "rustc X.Y not supported" →
wasm-bindgen-clibumped its MSRV. The Dockerfile currently usesrust:1-bookworm(latest stable). Pin inDockerfileif needed.