Sthora
Reference

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:5432 on a public VPS is how self-hosted platforms get their databases wiped; the compose file uses expose, never ports.
  • 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.ts asserts 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.

ComponentBudgetHeld by
Traefik~60 MBv3, no plugins, access log to stdout with rotation
PostgreSQL~250 MBshared_buffers=256MB, max_connections=50, work_mem=4MB
Redis~150 MBmaxmemory 128mb, maxmemory-policy noeviction
API + workers~350 MBOne Node process, --max-old-space-size=384
Dashboard SSR~150 MB--max-old-space-size=192
Total~1 GBleaving ~3 GB for applications and builds

Queues

BullMQ on Redis. Redis is also the cache and the log-fanout pub/sub bus.

QueueConcurrencyWork
build1Fetch source, detect, build the image
deploy2Render compose, up, health-gate, finalise
lifecycle4start / stop / restart / delete / rollback
metrics1Repeatable every 15s: sample host and containers
maintenance1Cron: rollups, retention, image prune, certificate check, platform DB backup
notify4Outbound 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.

OperationReached through
build, up, down, pull, psdocker compose, via the driver's execStream
inspect, stats, logs, events, system dfThe 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

APINestJS, TypeScript, ESM
DatabasePostgreSQL 17 via Prisma 7
Queue, cache, log busRedis 7.4, BullMQ
Dashboard, marketing site, this siteTanStack Start
IngressTraefik v3, Docker provider only
AuthFirst-party. No auth framework; the only third-party dependency is the Argon2id hashing library.
Secrets at restAES-256-GCM, keyed from a file outside the database
Live deliveryServer-Sent Events for metrics, build logs, and container logs
Agent channelA raw WebSocket at /agent/connect, carrying a correlated RPC

On this page