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:
parent
e4cf5a9bed
commit
c6f50bcb50
1 changed files with 228 additions and 0 deletions
228
DEPLOY.md
228
DEPLOY.md
|
|
@ -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 ~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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue