System architecture
How the running system fits together — control plane, Go agent, Postgres, and the dual-backend store pattern.
System architecture
HostSSH is a server control plane: one package installs on any VPS and turns it into a fully-managed server — deploy, browser-root access, hardening, monitoring, and an AI co-pilot — driven from a cloud dashboard. This page is the map of how the running system fits together. For step-by-step operator runbooks (deploys, migrations, the agent, the API), follow the cross-links into the hostssh operations skill.
Scope note: this is the current, code-grounded architecture. The older
docs/ARCHITECTURE.mdat the repo root is the original three-plane product spec; where they differ, trust the code and this page.
The three pieces
git push (Coolify auto-deploy)
│
┌───────────────────────────────────────────────────────────────┐
│ CONTROL PLANE — Next.js 16 (App Router, output: standalone) │
│ Coolify app `hostssh-dashboard` on app box 31.220.104.207 │
│ │
│ marketing site · /sign-in · god-mode dashboard · /healthz │
│ /api/v1/* agent endpoints ◄── rewrite from /v1/* │
└───────────────┬───────────────────────────────┬───────────────┘
│ SQL (porsager pool) │ HTTPS /v1/*
│ DATABASE_URL → 10.10.0.2:5432 │ Bearer <license key>
▼ ▼
┌──────────────────────────────┐ ┌───────────────────────────────┐
│ POSTGRES (pgvector, pg18) │ │ GO AGENT (one static binary) │
│ data box 31.220.104.211 │ │ on every managed / BYO VPS │
│ container t6cr90jl6qq19… │ │ CLI + systemd daemon + MCP │
│ WireGuard bridge 10.10.0.2 │ │ dials OUT — no inbound needed │
└──────────────────────────────┘ └───────────────────────────────┘
-
Next.js control plane (
control-plane/web) — the SaaS brain. It serves the public marketing site, sign-in, the operator dashboard, and the HTTP endpoints agents call. It runs as a self-containednode server.jsbundle on Coolify (app box31.220.104.207). Deploys happen bygit pushtomain; Coolify rebuilds the Dockerfile and redeploys (the Coolify REST API is filtered from the operator network, sogit push— not the API — is the live path). -
Go agent (
agent/) — a single static, zero-dependency binary installed on every server. It is the only component that touches a host: it captures and restores images, builds and runs apps, streams logs, heartbeats telemetry, and serves an MCP interface for AI agents. It always dials out to the control plane — no inbound connection to a managed box is ever required. -
Postgres (pgvector) — the single source of truth for persisted state, on a separate data box (
31.220.104.211, containert6cr90jl6qq19vuz7v3qv1fy), reached over a WireGuard bridge at10.10.0.2:5432. The control plane talks to it through a connection-pooled client; the agent never touches it directly.
The same architecture serves both the managed-hosting product and the standalone-licensable install — only who owns the box (and which provider drivers are wired) differs.
Control plane internals
Route groups (control-plane/web/app)
| Group | What it is |
|---|---|
(marketing) | Public site + blog (/), needs no database |
(auth) | /sign-in, MFA, early-access flows |
(god) | The operator dashboard — every panel (dashboard, fleet, deploy, licenses, tenants, images, recovery, provisioning, dns-tools, audit, slots, support, …), session-gated |
api/v1/* | The agent contract (see below) |
api/{chat,deploy} | Public chat (SSE) + session-gated deploy-log proxy |
healthz | Readiness probe: DB reachable, required tables present, migrations converged |
The (god) route group is the dashboard; its chrome (sidebar, topbar, ⌘K
palette, impersonation banner, launch-mode switcher) lives in
components/shell/, with one component directory per domain under components/
(fleet, platform, licenses, recovery, slots, …).
The agent ↔ control-plane contract
next.config.ts rewrites /v1/:path* → /api/v1/:path*. The agent posts to a
clean /v1/... surface; the handlers live under the App Router's /api
convention. No middleware gates /api, so an agent request authenticated by its
license key reaches the route without a dashboard session.
The agent never holds a browser session — its license key is its identity,
carried as Authorization: Bearer <key>. The core endpoints:
| Endpoint | Caller | Purpose |
|---|---|---|
POST /v1/license/activate | hostssh license activate | mint a signed ed25519 activation token |
POST /v1/telemetry/heartbeat | agent daemon (60 s) | report health; ack carries a signed refresh |
POST /v1/jobs/claim | agent daemon (15 s) | claim the next deploy/stop/prune job (204 = none) |
POST /v1/jobs/[id]/status · /logs | agent daemon | report progress + stream build logs back |
GET /v1/fleet/servers · /backups | hostssh fleet … | the license's fleet view |
POST /v1/monitors/run | external cron | run due DNS monitors (cron-secret auth) |
Two security properties are worth calling out because they shape the data flow:
- Signed-refresh, not bare-200. A heartbeat that returns HTTP 200 does not
prove entitlement. Only a signed, fingerprint-bound
refreshtoken in the ack advances the agent's offline-grace clock. The control plane signs it withHOSTSSH_LICENSE_PRIVKEY; the agent verifies it against a pinned public key. - Job ownership fencing. Claims are atomic (
FOR UPDATE SKIP LOCKED) and carry the agent's fingerprint; a stale claim is reclaimed after a 5-minute lease, and late writes from a dead agent are rejected by aclaimed_byfence.
Request bodies are validated by Zod schemas in lib/api/ (schemas.ts +
validate.ts). The validator is a tolerant receiver: unknown keys are stripped
(forward-compatible with newer agents) but wrong types / missing-required fields
are rejected with a 400. The schemas mirror the Go agent's wire structs — keep
them in sync or you 400 a legitimate agent.
Full route inventory, schemas, and auth gates: control-plane-api.md.
The dual-backend store pattern (useDb)
Every persisted domain follows one rule, defined in lib/db.ts:
export const useDb = Boolean(process.env.DATABASE_URL)
- When
DATABASE_URLis set, stores hit Postgres (apostgrespool ofmax: 5, cached onglobalThisso HMR and repeated imports share one connection). - When it is unset, the entire app runs on an in-memory mock — so local dev,
demos, and the marketing site need no database.
db()throws if the URL is unset, so every call site must guard withuseDbfirst.
Each store (lib/{fleet/agents,platform/jobs,licenses,audit,chat,...}/store.ts)
is shaped if (useDb) { …SQL… } else { …in-memory Map/array on globalThis… }.
lib/data.ts is the read aggregator that the dashboard renders from. Its key
discipline: in live mode it returns real data (self-registered agents + any
wired provider fleet) and its live(mock) helper returns [] rather than seed
rows — so an unwired panel shows an honest empty state, not a fake. The rich
showcase fleet only appears in demo (no-DB) mode, tagged demo: true and excluded
from headline fleet counts. This is what lets the same build run as a no-DB demo
and as the live control plane without branching the UI.
Schema management
Two mechanisms manage the schema, and they are deliberately separate:
- Baseline —
control-plane/db/*.sql, idempotent, applied out-of-band at go-live (it lives outside the Docker build context, so it is not in the image). - Boot-time migration runner —
lib/migrations/, forward-only numbered TypeScript migrations bundled into the standalone image.instrumentation.tsfiresrunMigrations()detached at boot (awaiting it would block readiness and risk a probe-timeout crash-loop); it never throws, logs[migrate][FATAL]on trouble, and/healthzreportspendinguntil the run converges. The runner holds a Postgres advisory lock so concurrent replica boots are safe.
This session added the runner itself, the first migrations (0001 audit-event
indexes, 0002 slots), and the least-privilege role split (scripts/setup-db-roles.sql):
the app runs as role hostssh (DML only) while migrations run as
hostssh_migrator (owns the schema, runs DDL) via MIGRATIONS_DATABASE_URL.
A leaked app credential can read and write rows but cannot alter or drop structure.
See database-migrations-roles.md.
The Go agent
A single binary (hostssh) is CLI, systemd daemon, and MCP server in one. The
defining constraint: zero third-party dependencies — agent/go.mod declares
only the module and Go version, there is no go.sum, and every import resolves to
the standard library or the agent's own packages. With CGO_ENABLED=0 this yields
one fully static binary per platform, no runtime libraries, and no supply-chain
surface to audit. External concerns (the Web-SSH PTY, transfer keys) are kept
behind interfaces specifically to preserve this property — do not add a
third-party import.
main.go dispatches to internal/cli/cli.go, which routes to per-concern
packages under internal/:
| Package | Responsibility |
|---|---|
engine | capture / restore — encrypted .hsi images (never license-gated) |
deploy, builder, runtime, proxy | build (HostPack) → run (Docker) → route (Traefik) |
database, secrets | provision managed Postgres/MariaDB/Redis; store secrets |
license, fingerprint, secure | activation, the offline-grace gate, TLS pinning |
telemetry, jobs, fleet | heartbeat, claim-and-run jobs, fleet queries |
drill, retention, reaper | restore-drills, image retention, Docker disk GC |
audit | a tamper-evident local action log |
access, transfer | Web-SSH frame bridge, one-time peer transfer keys |
mcp, mcpkit | stdio MCP server so AI agents can drive the platform |
The daemon loop
hostssh agent (run by systemd) starts background goroutines plus a small HTTP
server on 127.0.0.1:8765:
- Heartbeat every 60 s (control-plane re-paceable) — posts host + health, and only advances the grace clock on a verified signed refresh.
- Jobs every 15 s, serially — claims one job, runs it through the build→run→route engine, and streams logs back (a 30-minute timeout force-kills a wedged build).
- Reaper every 24 h — keeps the last-N images per app and bounds the build cache.
- HTTP —
/healthzand a/logsendpoint the control-plane log viewer proxies.
Licensing and the offline-grace gate
Recovery (capture / restore) is never gated — your data is never held
hostage. Feature commands (deploy, db, mcp, …) require an active or in-grace
license; enforceLicense runs before dispatch and exits 12 when blocked. The
gate (internal/license/gate.go) is offline-first: a valid unexpired signed token
is active with no network call; otherwise the agent runs on a grace window
(default 14 days) that only a signed refresh extends. The trust root (the ed25519
public key the agent pins, and the TLS SPKI pins) is baked into release binaries
via ldflags and locked. Full model:
agent.md.
God-mode and RBAC
The dashboard is operator-facing and fail-closed:
- Session (
lib/session.ts) — a signed JWT cookie (hs_session, 12 h). It resolves to an admin (super_adminfor the root, elseadmin) or a product user. Layered "god-mode" cookies carry launch mode, simulation, impersonation, and density. - RBAC (
lib/rbac.ts) —super_admingets everything; sub-admins get only their grantedpermissions[]; aSUPER_ONLYset (e.g.admin.manage_admins,hardware.manage) is never delegable. The catalog gates each panel and mutation (platform.deploy,fleet.*,licenses.manage,dns.*,audit.view, …). - Server actions (
lib/actions.ts) — every god-mode mutation is independently POST-invokable, so each re-checksrequireAdmin(permission?)rather than trusting the rendering page. - Audit trail (
lib/audit/store.ts) — sensitive operator actions append to theaudit_eventstable (dual-backend, best-effort, never throws into the action it records). This was added this session, alongside the indexes in migration0001.
Note the two distinct audit logs: this control-plane audit_events table (operator
actions in the dashboard) and the agent's own tamper-evident on-box log
(internal/audit). They serve different trust boundaries.
The rate limiter (lib/ratelimit.ts) is per-process and in-memory — effective for
the single Coolify container today, but it resets on deploy and is not shared across
replicas; it must move to Redis before horizontal scaling.
Component map
| Layer | Lives in | Key files |
|---|---|---|
| Marketing / blog | app/(marketing), components/marketing | lib/blog.ts, lib/seo.ts |
| Dashboard pages | app/(god)/* | one folder per panel |
| Dashboard chrome | components/shell | sidebar.tsx, topbar.tsx, command-palette.tsx |
| Domain UI | components/{fleet,platform,licenses,recovery,slots,…} | — |
| Agent endpoints | app/api/v1/* | + next.config.ts rewrite |
| Request validation | lib/api | schemas.ts, validate.ts |
| Data access | lib/data.ts + lib/*/store.ts | gated by lib/db.ts (useDb) |
| Jobs / deploy | lib/platform | jobs.ts, reflect.ts, signing.ts |
| Auth / RBAC / audit | lib/{session,rbac,actions}.ts, lib/{auth,audit} | — |
| Migrations | lib/migrations, instrumentation.ts | runner.ts, core.ts, list.ts |
| Agent | agent/internal/* | cli/cli.go, license, telemetry, jobs |
Where to go next
- FRAMEWORK.md — HostSSH as the fleet's infrastructure framework.
- PLATFORM.md — the build engine (HostPack), the 100%-ownable stack decision, and the phased roadmap.
- agent-protocol.md · api-and-cli.md — the wire formats and command surface in detail.
- control-plane-dashboard.md · control-panel.md — the dashboard and the on-box panel.
- Slots model — the capacity/seat abstraction added this
session (
lib/slots/, the/slotspanel, migration0002). - Operator runbooks (deploys, migrations, agent, API, ops): the hostssh skill references — deploy-and-cd · database-migrations-roles · agent · control-plane-api · operations-and-process.
I wrote `docs/dev/architecture.md` grounded in the cited code (`next.config.ts`, `lib/db.ts`, `instrumentation.ts`, `lib/data.ts`, `agent/internal/cli/cli.go`, the `app`/`lib`/`components` trees, MASTER_PLAN, and the four skill references). It covers the three pieces, the `useDb` dual-backend pattern, the standalone deploy shape, the `/v1` rewrite + license-bearer contract (including signed-refresh and job-fencing), god-mode/RBAC, and a component map, with an ASCII diagram and cross-links to the sibling dev docs and the operations skill. Returning only the Markdown body above as the canonical page content.