← Back to portfolio

Personal project / Solo developer

Nodewake

Factorio hosting that starts a server when a player connects, then saves, stops, and verifies a backup when the server sits empty.

01 / Container platform

Docker & AWS

I split the platform into separate containers, with a worker managing the game’s lifecycle.

Restrict Docker host access

Why
Creating game containers requires powerful host privileges. I kept those privileges out of the web API and public relay to limit what a compromised service could do.
Built
  • The worker calls a private host agent; only the socket proxy mounts the Docker socket. The agent constructs the permitted container configuration before calling Docker through the proxy.
  • Ownership and epoch checks reject unmanaged containers and stale worker requests. The agent restricts capabilities and sets resource and log limits; the proxy separately limits available Docker API endpoints.
Result
The public API and relay can request lifecycle changes without access to arbitrary host workloads. Container configuration and host operations stay behind the agent’s checks and the proxy’s permitted endpoints.

Separate infrastructure from the game lifecycle

Why
A game session needs readiness checks, save commands, and a shutdown sequence in addition to container orchestration.
Built
  • Amazon ECS runs the cloud containers; Terraform defines their infrastructure.
  • Nodewake’s Python services decide when a game is needed, check whether players can join, and coordinate saves and shutdown. Manual starts and player-triggered wakes use that same lifecycle.
Result
Infrastructure definitions can change separately from game-session behavior. The game’s save protocol and readiness rules stay in the application code rather than becoming part of provisioning logic.
Inspect the Docker deployment 11 containers · connections & permissions
Core Docker deploymentOpen full diagram ↗
Nodewake core Docker deploymentSeparate containers for the HTTPS edge, authentication, API, PostgreSQL, worker, host agent, socket proxy, game server, Quilkin, Python relay, and relay gate. Each arrow is labelled with its protocol or permitted operation. browser:e->caddy:w HTTPS player:e->quilkin:w UDP caddy:n->auth:s auth caddy:e->api:w HTTP api:e->db:w SQL worker:w->db:e SQL worker:s->agent:n HTTP quilkin:e->relay:w UDP relay:e->gate:w HTTP relay:s->game:n UDP gate:n->db:s SQL agent:w->game:e RCON agent:s->proxy:n HTTP daemon:w->game:e run proxy:w->daemon:e Unix browser Owner Browser / CLI player Player Factorio client auth Authelia Sign-in + second factor caddy Caddy HTTPS / static web / API api API Python / FastAPI db PostgreSQL State + leadership worker Worker Reconciler / epoch fence quilkin Quilkin Public UDP proxy relay Python relay Join check / buffer / replay gate Relay gate Scoped wake + status agent Host agent Guarded game operations game Factorio Game-server container daemon Docker daemon Host process proxy Socket proxy Allowed Docker API calls
  • Blue: control / state
  • Orange: player UDP
  • Cream: Docker host operations
  • Solid: containers · dashed: clients / host process

Core runtime only. Monitoring, build images, migration jobs, and Grafana’s launcher are omitted.

02 / Backend coordination

Startup & worker failover

Manual starts and player connections share one control path.

Reconcile starts through PostgreSQL

Why
Starting containers directly from the relay would duplicate API logic and require broader host permissions. Worker handovers also need to prevent an old leader from continuing to make changes.
Built
  • PostgreSQL stores the desired state. The API records requests; the relay uses a gate whose database role can call only wake and status functions. One worker compares that state with the running game and asks the host agent to make changes.
  • A leader lock selects the worker. Increasing leadership versions (epochs) let the database and host agent reject stale work. The lock selects who should lead; the epoch checks fence off an old leader.
Result
Manual starts and player connections use the same startup logic. Tests cover a worker losing leadership during a pass and refusing to commit its transition; the relay’s credentials do not grant general session-management access.

Keep web controls outside the runtime

Why
An owner needs to sign in and start games without giving web services Docker host privileges.
Built
  • Caddy serves the static TypeScript/Next.js frontend and routes API requests to FastAPI.
  • Authelia handles sign-in and a second factor. An owner pressing Start reaches the same database and worker path as a player connecting.
Result
Owner controls stay separate from privileged runtime operations. Authentication, web serving, and the API request changes through the control plane; the host agent performs the container operations.

Check when the game can accept players

Why
A container can be running while the map is still loading. Process startup alone does not mean a player can join.
Built
  • The worker verifies hosting state and RCON (remote console) responses before recording readiness.
  • The relay watches for the first UDP reply and starts forwarding immediately, independently of the worker’s checks.
Result
Players can connect as soon as the game responds, without waiting for the control plane’s next polling interval. The worker still records readiness only after its game checks pass.
03 / Edge networking

Handling cold starts

Factorio can stop sending join requests before a server finishes loading.

Put Quilkin at the public edge

Why
I chose a maintained, memory-safe public UDP proxy while keeping custom game logic in Python.
Built
  • Quilkin owns the public game port and forwards UDP traffic to the Python relay over a private network.
  • The relay owns join validation and wake requests, along with buffering and replay. It asks the restricted gate to wake the game; Quilkin forwards packets without taking on game-management logic.
Result
The public proxy has no route to the management API or Docker agent. Its network exposure and packet-forwarding role remain separate from the custom relay’s game-specific behavior.

Buffer join packets through startup

Why
Factorio’s client sends join requests early, then waits. Those requests would otherwise be lost while a stopped server is starting.
Built
  • The asyncio relay buffers a limited number of packets per player and replays them every 250 ms until the game answers. It then becomes a normal forwarder.
  • Join validation, per-source cooldowns, and an hourly wake cap limit unwanted starts from stray or repeated traffic.
Result
In the recorded small-save test, the client joined a stopped server in about three seconds with one Connect action. Larger saves can exceed the client’s ten-second window and need another attempt.

Factorio 2.0.77 with a small save. Larger saves can exceed the client’s 10-second window and need another connection attempt.

04 / Recovery & operations

Backups & operations

An empty server shuts down with a verified local backup. Restores preserve the current world first.

Verify backups and protect restores

Why
Stopping a process does not confirm that a usable save exists. Restoring an older backup also needs to preserve the current world in case the older save is wrong.
Built
  • Verified shutdown: warn players, save the game, stop the container, create a local backup, and verify it. Integration tests cover a save, backup, restore, and boot round trip with Factorio and local storage.
  • Archive before restore: Nodewake keeps the current save before replacement. If backup verification fails, the live world stays untouched rather than being partially replaced.
Result
Tampered backups leave the live save untouched. A completed stop produces a verified backup, and the pre-restore world remains available after a restore.

Monitor failed wakes and proxy overload

Why
A proxy can keep running while rejecting new sessions. A start request can also fail before the game responds.
Built
  • Prometheus scrapes private metrics from the API, worker, relay, and Quilkin. Grafana displays them, and Alertmanager routes notifications.
  • Alerts cover proxy availability, rejected sessions, slow starts, and an unreachable gate. Resource limits and restart/direct-play runbooks address known failure paths.
Result
An operator can distinguish gate failures, proxy overload, and a game that has not answered. The runbooks cover restarting the proxy or connecting directly when the normal edge path is unavailable.

A flood can fill Quilkin’s session table and require a proxy restart; the runbook covers recovery and direct connections.

Testing the failure paths

The repository’s unit and integration tests cover startup, permissions, worker handover, and recovery.

  • Relay behavior. Replay buffered packets once the game responds, reject invalid wake traffic, and avoid duplicate wakes when another player joins.
  • Permissions. Reject unmanaged containers and unapproved mounts. Check the gate’s database role, container capabilities, and resource limits.
  • Worker handover. Allow one leader at a time. Reject state writes from stale epochs or workers that lose leadership mid-pass.
  • Recovery. Save, back up, restore, and boot a game. Reject a tampered backup without changing the live save.