Deploy to a DigitalOcean droplet: wizwar is on the internet
Production shape: one Node process serves the built client and the websocket on a single port (SPA fallback, same-origin wss in the client), with Caddy terminating auto-TLS in front. The droplet (nyc3, $6/mo) runs it under systemd as an unprivileged user with room files on the persistent disk at /var/lib/wizwar/rooms — deploys and reboots cannot eat a game. deploy/ carries the one-time droplet setup script, the systemd unit, the Caddyfile, an everyday deploy script (build locally, rsync, install, restart), and a README. Live at https://wizwar.104.236.96.198.sslip.io via sslip.io, so TLS needed no DNS setup at all. Verified over the real internet: room created, second player joined, expansion game started, hands dealt, room file persisted. Fixed en route: rsync's unanchored "data" exclude was stripping the engine's card database, and tsx must ship (it is the runtime). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
002dbd2547
commit
23bdd009a0
@@ -0,0 +1,6 @@
|
|||||||
|
# Caddy terminates TLS (automatic certificates) and proxies to the game.
|
||||||
|
# Replace the hostname with your domain, or use the sslip.io form
|
||||||
|
# (wizwar.<droplet-ip>.sslip.io) for zero DNS setup.
|
||||||
|
{$WIZWAR_HOST}
|
||||||
|
|
||||||
|
reverse_proxy localhost:8787
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Deploying Wiz-War
|
||||||
|
|
||||||
|
The game runs on a single DigitalOcean droplet: one Node process serves the
|
||||||
|
built client and the websocket on port 8787; Caddy in front terminates TLS
|
||||||
|
with automatic certificates. Room files persist in /var/lib/wizwar/rooms and
|
||||||
|
survive deploys and reboots.
|
||||||
|
|
||||||
|
## Current production
|
||||||
|
|
||||||
|
- Droplet: `wizwar` (nyc3, s-1vcpu-1gb, tag `wizwar`), IP 104.236.96.198
|
||||||
|
- URL: https://wizwar.104.236.96.198.sslip.io
|
||||||
|
(sslip.io resolves the embedded IP — zero DNS setup. To use a real domain,
|
||||||
|
point an A record at the droplet and update /etc/caddy/Caddyfile.)
|
||||||
|
|
||||||
|
## Everyday deploys
|
||||||
|
|
||||||
|
deploy/deploy.sh 104.236.96.198
|
||||||
|
|
||||||
|
Builds the client locally, rsyncs the repo (minus node_modules, /data, .git,
|
||||||
|
research), installs dependencies on the droplet, and restarts the service.
|
||||||
|
Games in progress survive: state lives in room files, and clients reconnect
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
## New droplet from scratch
|
||||||
|
|
||||||
|
1. `doctl compute droplet create wizwar --region nyc3 --size s-1vcpu-1gb \
|
||||||
|
--image ubuntu-24-04-x64 --ssh-keys <your-key-ids> --tag-name wizwar --wait`
|
||||||
|
2. `scp deploy/setup-droplet.sh root@<ip>:/root/ && ssh root@<ip> \
|
||||||
|
"bash /root/setup-droplet.sh wizwar.<ip>.sslip.io"`
|
||||||
|
3. `deploy/deploy.sh <ip>`
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
- Logs: `ssh root@<ip> journalctl -u wizwar -f`
|
||||||
|
- Restart: `ssh root@<ip> systemctl restart wizwar`
|
||||||
|
- Backup games: `scp -r root@<ip>:/var/lib/wizwar/rooms ./rooms-backup`
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build locally, push to the droplet, restart. Usage: deploy/deploy.sh <droplet-ip>
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${1:?usage: deploy.sh <droplet-ip-or-host>}"
|
||||||
|
|
||||||
|
npm run build --workspace=@wizwar/web
|
||||||
|
rsync -az --delete \
|
||||||
|
--exclude node_modules --exclude /data --exclude .git --exclude research \
|
||||||
|
./ "root@$HOST:/opt/wizwar/"
|
||||||
|
ssh "root@$HOST" '
|
||||||
|
cd /opt/wizwar && npm install --no-audit --no-fund
|
||||||
|
chown -R wizwar:wizwar /opt/wizwar
|
||||||
|
cp /opt/wizwar/deploy/wizwar.service /etc/systemd/system/wizwar.service
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now wizwar
|
||||||
|
systemctl restart wizwar
|
||||||
|
systemctl --no-pager -l status wizwar | head -5
|
||||||
|
'
|
||||||
|
echo "deployed."
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# One-time droplet setup. Run ON the droplet as root:
|
||||||
|
# bash setup-droplet.sh wizwar.<droplet-ip>.sslip.io
|
||||||
|
set -euo pipefail
|
||||||
|
HOST="${1:?usage: setup-droplet.sh <hostname>}"
|
||||||
|
|
||||||
|
apt-get update -q
|
||||||
|
apt-get install -qy curl git rsync
|
||||||
|
|
||||||
|
# Node 22
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||||
|
apt-get install -qy nodejs
|
||||||
|
|
||||||
|
# Caddy (auto-HTTPS)
|
||||||
|
apt-get install -qy debian-keyring debian-archive-keyring apt-transport-https
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
|
||||||
|
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
|
||||||
|
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
|
||||||
|
| tee /etc/apt/sources.list.d/caddy-stable.list
|
||||||
|
apt-get update -q && apt-get install -qy caddy
|
||||||
|
|
||||||
|
# App user + directories
|
||||||
|
id -u wizwar &>/dev/null || useradd -r -m -d /opt/wizwar-home wizwar
|
||||||
|
mkdir -p /opt/wizwar /var/lib/wizwar/rooms
|
||||||
|
chown -R wizwar:wizwar /opt/wizwar /var/lib/wizwar
|
||||||
|
|
||||||
|
# Caddy vhost
|
||||||
|
printf '%s\n\nreverse_proxy localhost:8787\n' "$HOST" > /etc/caddy/Caddyfile
|
||||||
|
systemctl reload caddy
|
||||||
|
|
||||||
|
echo "droplet ready — now run deploy.sh from your machine"
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Wiz-War game server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=wizwar
|
||||||
|
WorkingDirectory=/opt/wizwar/packages/server
|
||||||
|
Environment=PORT=8787
|
||||||
|
Environment=WIZWAR_DATA_DIR=/var/lib/wizwar/rooms
|
||||||
|
Environment=WIZWAR_STATIC_DIR=/opt/wizwar/packages/web/dist
|
||||||
|
ExecStart=/usr/bin/npx tsx src/index.ts
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -11,6 +11,9 @@
|
|||||||
// {type:"state", view} redacted full view (after every change)
|
// {type:"state", view} redacted full view (after every change)
|
||||||
// {type:"error", message}
|
// {type:"error", message}
|
||||||
|
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { extname, join, normalize } from "node:path";
|
||||||
import { WebSocketServer, WebSocket } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import type { Command, PlayerId } from "@wizwar/engine";
|
import type { Command, PlayerId } from "@wizwar/engine";
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +33,33 @@ import {
|
|||||||
|
|
||||||
const port = Number(process.env.PORT ?? 8787);
|
const port = Number(process.env.PORT ?? 8787);
|
||||||
loadPersistedRooms();
|
loadPersistedRooms();
|
||||||
const wss = new WebSocketServer({ port });
|
|
||||||
|
// One process serves both the built client and the websocket, so production
|
||||||
|
// needs only a TLS proxy in front (or nothing, on a LAN).
|
||||||
|
const STATIC_DIR = process.env.WIZWAR_STATIC_DIR ?? join(process.cwd(), "..", "web", "dist");
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".html": "text/html", ".js": "text/javascript", ".css": "text/css",
|
||||||
|
".png": "image/png", ".svg": "image/svg+xml", ".ico": "image/x-icon",
|
||||||
|
".woff2": "font/woff2", ".json": "application/json",
|
||||||
|
};
|
||||||
|
const httpServer = createServer((req, res) => {
|
||||||
|
try {
|
||||||
|
const url = (req.url ?? "/").split("?")[0]!;
|
||||||
|
let file = normalize(join(STATIC_DIR, url === "/" ? "index.html" : url));
|
||||||
|
if (!file.startsWith(normalize(STATIC_DIR))) {
|
||||||
|
res.writeHead(403).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!existsSync(file)) file = join(STATIC_DIR, "index.html"); // SPA fallback
|
||||||
|
const body = readFileSync(file);
|
||||||
|
res.writeHead(200, { "content-type": MIME[extname(file)] ?? "application/octet-stream" });
|
||||||
|
res.end(body);
|
||||||
|
} catch {
|
||||||
|
res.writeHead(500).end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const wss = new WebSocketServer({ server: httpServer, path: undefined });
|
||||||
|
httpServer.listen(port);
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
socket: WebSocket;
|
socket: WebSocket;
|
||||||
@@ -182,4 +211,4 @@ wss.on("connection", (socket) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(`wizwar server listening on ws://localhost:${port}`);
|
console.log(`wizwar serving client + websocket on port ${port}`);
|
||||||
|
|||||||
@@ -3,7 +3,11 @@
|
|||||||
import type { Command, GameEvent, GameView } from "@wizwar/engine";
|
import type { Command, GameEvent, GameView } from "@wizwar/engine";
|
||||||
import { cardDef } from "@wizwar/engine";
|
import { cardDef } from "@wizwar/engine";
|
||||||
|
|
||||||
const SERVER_URL = import.meta.env.VITE_WIZWAR_SERVER ?? "ws://localhost:8787";
|
const SERVER_URL =
|
||||||
|
import.meta.env.VITE_WIZWAR_SERVER ??
|
||||||
|
(location.hostname === "localhost"
|
||||||
|
? "ws://localhost:8787"
|
||||||
|
: `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws`);
|
||||||
|
|
||||||
export function humanize(e: GameEvent): string | null {
|
export function humanize(e: GameEvent): string | null {
|
||||||
switch (e.type) {
|
switch (e.type) {
|
||||||
|
|||||||
Reference in New Issue
Block a user