This is a continuation of Setting up a personal VPS — a foundation for everything. The short version of that post: OVHcloud server, Ubuntu 24.04, Docker, Caddy reverse proxy, Tailscale for private access. A clean foundation with nothing interesting running on it yet.
The first real workloads were game servers. CS2, Minecraft, Valheim. And immediately the problem: managing them means SSHing in, running docker commands by hand, tailing logs in a terminal. Fine once. Not fine as a routine. I wanted a web interface that actually understood my setup.
Nothing off the shelf fit. Generic server dashboards don't know about game servers. Game server panels don't know about the rest of the VPS. I decided to build something.
The stack
Next.js 16 with the App Router, TypeScript, SQLite + Drizzle ORM, Tailwind CSS. Deployed as a Docker container, accessible only through Tailscale — not public-facing. The whole thing lives behind authentication so even if it were accidentally exposed, it's not immediately a problem.
SQLite was a deliberate choice. This is a single-user tool with light write traffic. SQLite is fast, file-based, zero-configuration, and the database is a single volume-mounted file. Drizzle handles the schema and migrations. The combination is simple and it's been solid.
Game server management
Each game server is a Docker container. The dashboard provisions them through the Docker API: pull the image, create the container with the right memory cap, CPU allocation, volume mounts, and port bindings, start it. Stop, restart, and delete work the same way.
This is the most infrastructure-heavy part. Each game has different requirements. Minecraft needs specific JVM flags. Valheim wants persistent world files in a particular path and handles updates differently. CS2 runs a long Steam download before the actual server is ready. The provisioning logic for each game is its own module — there's no generic "game server" template that works for all of them.
The dashboard tracks each server's state, notes, last-started time, and restart schedule. You can view the last N lines of logs, send console commands where the game supports it, and configure scheduled restarts. Not earth-shattering features, but not having to open a terminal for any of it is genuinely nice.
Resource monitoring
A background job records CPU, memory, and disk usage every five minutes to SQLite. The dashboard plots these with Recharts across 1h, 6h, 24h, and 7d time windows.
The interesting constraint here is query performance. Thirty days of five-minute samples is around 8,600 rows per metric. Without an index on the timestamp column, range queries are a full table scan and it shows. With the index they're instant. Worth mentioning because it's easy to miss when the table starts small.
-- Without this, 7-day queries noticeably drag
CREATE INDEX resource_samples_created_at_idx
ON resource_samples (created_at);
The AI assistant
This is the part I'm most pleased with.
The dashboard has a built-in chat assistant running on Gemini via its OpenAI-compatible API endpoint. The assistant has a defined set of tools it can call: get system stats, list and control game servers, check disk usage, read the audit log, provision a new server, set threshold alerts, query the capacity forecast, and more. It uses these to answer questions and take actions in plain English.
The architecture is the standard LLM tool-use loop — model decides which tool to call, backend executes it and returns the result, model incorporates the result and responds or calls another tool. The response streams token-by-token via Server-Sent Events so the UI feels responsive.
In practice: "how's the server doing?" triggers a multi-tool response — checks system stats, game server statuses, and recent audit events, then summarises. "Start the Minecraft server" finds the server, starts it, and confirms. "How much disk do I have left, will it be a problem?" fetches disk stats and runs the capacity forecast and gives an actual answer with a timeline.
Anomaly detection
Every five minutes, alongside recording a new resource sample, a background job checks whether the current reading is statistically unusual. The comparison window is the last seven days of data for the same time of day — specifically, samples taken within one hour either side of the current hour. This gives a baseline that accounts for natural daily patterns.
If the current value is more than three standard deviations above the baseline mean — and it clears a minimum absolute threshold to filter out noise in low-usage periods — it's flagged as an anomaly. A Discord DM goes out immediately. There's a two-hour cooldown per metric so a sustained spike doesn't send repeated notifications.
The time-of-day window is the key design decision. CPU at 3am has a completely different baseline than CPU at 7pm when a game server is actively running. A flat all-day average would produce constant false positives during peak hours and miss genuine problems during quiet ones. The sliding hourly window fixes this without needing anything more complicated.
Anomaly events are stored in the database and surfaced in a notification feed in the dashboard header alongside crash events, restarts, and anything else worth knowing about.
Capacity forecasting
Linear regression over the last seven days of disk usage gives a slope in percentage points per day. If disk is growing meaningfully, the resource chart shows a dashed extrapolation line into the future and a "full in N days" badge — red below seven days, amber under fourteen, grey beyond that.
It's a simple model. It assumes growth continues at the current rate, which isn't always true, but for the "disk is filling steadily because something is accumulating" problem it's accurate enough to be useful. The AI assistant can query this directly: "will I run out of disk?" runs the same regression and returns a plain-English estimate.
Live log streaming
Game server consoles stream in real time via Server-Sent Events. The Docker API returns container logs as a multiplexed binary stream — each frame has an 8-byte header encoding whether the payload is stdout or stderr and how long the payload is, followed by the actual content. The SSE route strips those headers and emits each line as an event. The browser picks them up through the EventSource API and appends them to the console view, capped at 500 lines.
This replaced the previous approach of polling the logs endpoint every five seconds. The improvement is obvious — crash messages, player joins, and console output appear the moment they happen rather than up to five seconds later. The pulsing "Live" indicator in the console tab is also a nice touch. Small things matter.
Self-healing crash recovery
Each game server has an optional auto-restart flag. A background job checks container states every minute. If a container has exited but no stop was recently issued from the dashboard, it's treated as a crash — the container is restarted, an event is written to the audit log, and a Discord DM fires.
Tracking "was this a manual stop or a crash" is the subtle bit. The dashboard records a stop intent when you click the stop button. If the container exits without that intent being set, it's a crash. The distinction matters — you don't want an auto-restart loop when you deliberately stopped a server.
What I learned
Drizzle's migration runner applies all pending migrations in a single transaction. If any migration in the batch fails — even because it's a duplicate trying to create a table that already exists — the whole batch rolls back including the legitimate new migrations behind it. I spent longer than I'd like debugging why a new table wasn't appearing after deploys. The root cause: the Dockerfile was running drizzle-kit generate during the Docker build, which regenerated migration files from outdated local snapshots and produced duplicates. Removing the generate step from the Docker build and making all migration SQL idempotent with CREATE TABLE IF NOT EXISTS fixed it. Keep your migration files in source control and never regenerate them in CI or a build container.
Next.js App Router routes are statically rendered by default. Any route that reads from the database at request time needs export const dynamic = "force-dynamic". Miss it and you get a cached response from build time, which for a live dashboard is always wrong. This is one of those things that seems obvious in hindsight and cost me a confused twenty minutes of wondering why my stats weren't updating.
The Docker API's log stream format is documented but easy to miss. The 8-byte header per frame is: 1 byte for stream type (1 = stdout, 2 = stderr), 3 bytes padding, 4 bytes big-endian payload length. If you're consuming this in Node without a library that demuxes it, you get the raw binary prepended to every log line. Strip the header before forwarding to the client.
What's next
The dashboard does what I need. I'll add game support as I spin up new servers and there are a few more AI tools I want — browsing saved backups, more granular process management. But mostly this is running well and I'm happy with it.
The full feature breakdown and tech stack is on the projects page.