Architecture
What runs where, why it is arranged that way, and the memory budget that decided most of it.
Runtime topology
Everything the installer starts, on one Docker network called sthora-proxy:
:80 / :443
│
┌────▼─────┐ ACME HTTP-01, docker provider
│ traefik │────────────────────────┐
└────┬─────┘ │
fallback │ resource paths │ labels
┌───────────────▼──┐ ┌───▼──────────────┐ │
│ sthora-web │ │ sthora-api │ │
│ (TanStack Start) │ │ (NestJS + BullMQ)│ │
└──────────────────┘ └───┬──────────┬───┘ │
│ │ │
┌─────────▼──┐ ┌────▼─────┐ │
│ postgres │ │ redis │ │
└────────────┘ └──────────┘ │
│ │
/var/run/docker.sock │
│ │
┌──────────────▼────────────────────▼──┐
│ user app compose projects: st-<slug> │
└───────────────────────────────────────┘- Postgres and Redis publish no host ports. Binding
5432:5432on a public VPS is how self-hosted platforms get their databases wiped; the compose file usesexpose, neverports. - The API mounts the Docker socket, plus
/proc:/host/proc:ro,/sys:/host/sys:ro, and/:/host/root:ro,rslave. Without the host mounts every metric it reports is the container's, not the machine's. - The worker is deliberately not on the proxy network. It serves no HTTP and nothing should be able to route to it.
- The Traefik dashboard is never exposed, and the socket Traefik mounts is read-only.
proxy.service.spec.tsasserts both.
One API container runs both HTTP and workers (APP_ROLE=all) on a small box. The same image
runs with APP_ROLE=api or APP_ROLE=worker where they scale separately.
The memory budget
The target box is 2 vCPU / 4 GB / 40 GB.
| Component | Budget | Held by |
|---|---|---|
| Traefik | ~60 MB | v3, no plugins, access log to stdout with rotation |
| PostgreSQL | ~250 MB | shared_buffers=256MB, max_connections=50, work_mem=4MB |
| Redis | ~150 MB | maxmemory 128mb, maxmemory-policy noeviction |
| API + workers | ~350 MB | One Node process, --max-old-space-size=384 |
| Dashboard SSR | ~150 MB | --max-old-space-size=192 |
| Total | ~1 GB | leaving ~3 GB for applications and builds |
Queues
BullMQ on Redis. Redis is also the cache and the log-fanout pub/sub bus.
| Queue | Concurrency | Work |
|---|---|---|
build | 1 | Fetch source, detect, build the image |
deploy | 2 | Render compose, up, health-gate, finalise |
lifecycle | 4 | start / stop / restart / delete / rollback |
metrics | 1 | Repeatable every 15s: sample host and containers |
maintenance | 1 | Cron: rollups, retention, image prune, certificate check, platform DB backup |
notify | 4 | Outbound email and webhooks |
Workers register a graceful shutdown handler — a SIGTERM mid-build fails the deployment
cleanly rather than leaving it running forever — and a stalled-job reaper marks orphaned
deployments FAILED on boot.
Compose lifecycle: shell out, do not reimplement
Compose v2 has no stable library API.
| Operation | Reached through |
|---|---|
build, up, down, pull, ps | docker compose, via the driver's execStream |
inspect, stats, logs, events, system df | The Docker Engine API, via dockerode |
Mixing them this way is deliberate. Do not try to drive Compose through the API, and do not shell out for stats in a loop.
The one abstraction everything else rests on
Every Docker and filesystem operation is a ServerDriver call.
LocalServerDriver is the local machine; AgentServerDriver is the same calls over a
WebSocket. That is what makes managing a second machine a new file rather than a rewrite.
The stack
| API | NestJS, TypeScript, ESM |
| Database | PostgreSQL 17 via Prisma 7 |
| Queue, cache, log bus | Redis 7.4, BullMQ |
| Dashboard, marketing site, this site | TanStack Start |
| Ingress | Traefik v3, Docker provider only |
| Auth | First-party. No auth framework; the only third-party dependency is the Argon2id hashing library. |
| Secrets at rest | AES-256-GCM, keyed from a file outside the database |
| Live delivery | Server-Sent Events for metrics, build logs, and container logs |
| Agent channel | A raw WebSocket at /agent/connect, carrying a correlated RPC |