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.
341 lines
11 KiB
Markdown
341 lines
11 KiB
Markdown
# 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` → `<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:
|
||
|
||
```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 <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.
|