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.md at 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 │
  └──────────────────────────────┘   └───────────────────────────────┘
  1. 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-contained node server.js bundle on Coolify (app box 31.220.104.207). Deploys happen by git push to main; Coolify rebuilds the Dockerfile and redeploys (the Coolify REST API is filtered from the operator network, so git push — not the API — is the live path).

  2. 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.

  3. Postgres (pgvector) — the single source of truth for persisted state, on a separate data box (31.220.104.211, container t6cr90jl6qq19vuz7v3qv1fy), reached over a WireGuard bridge at 10.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)

GroupWhat 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
healthzReadiness 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:

EndpointCallerPurpose
POST /v1/license/activatehostssh license activatemint a signed ed25519 activation token
POST /v1/telemetry/heartbeatagent daemon (60 s)report health; ack carries a signed refresh
POST /v1/jobs/claimagent daemon (15 s)claim the next deploy/stop/prune job (204 = none)
POST /v1/jobs/[id]/status · /logsagent daemonreport progress + stream build logs back
GET /v1/fleet/servers · /backupshostssh fleet …the license's fleet view
POST /v1/monitors/runexternal cronrun 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 refresh token in the ack advances the agent's offline-grace clock. The control plane signs it with HOSTSSH_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 a claimed_by fence.

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_URL is set, stores hit Postgres (a postgres pool of max: 5, cached on globalThis so 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 with useDb first.

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:

  • Baselinecontrol-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 runnerlib/migrations/, forward-only numbered TypeScript migrations bundled into the standalone image. instrumentation.ts fires runMigrations() detached at boot (awaiting it would block readiness and risk a probe-timeout crash-loop); it never throws, logs [migrate][FATAL] on trouble, and /healthz reports pending until 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 dependenciesagent/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/:

PackageResponsibility
enginecapture / restore — encrypted .hsi images (never license-gated)
deploy, builder, runtime, proxybuild (HostPack) → run (Docker) → route (Traefik)
database, secretsprovision managed Postgres/MariaDB/Redis; store secrets
license, fingerprint, secureactivation, the offline-grace gate, TLS pinning
telemetry, jobs, fleetheartbeat, claim-and-run jobs, fleet queries
drill, retention, reaperrestore-drills, image retention, Docker disk GC
audita tamper-evident local action log
access, transferWeb-SSH frame bridge, one-time peer transfer keys
mcp, mcpkitstdio 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/healthz and a /logs endpoint 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_admin for the root, else admin) or a product user. Layered "god-mode" cookies carry launch mode, simulation, impersonation, and density.
  • RBAC (lib/rbac.ts) — super_admin gets everything; sub-admins get only their granted permissions[]; a SUPER_ONLY set (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-checks requireAdmin(permission?) rather than trusting the rendering page.
  • Audit trail (lib/audit/store.ts) — sensitive operator actions append to the audit_events table (dual-backend, best-effort, never throws into the action it records). This was added this session, alongside the indexes in migration 0001.

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

LayerLives inKey files
Marketing / blogapp/(marketing), components/marketinglib/blog.ts, lib/seo.ts
Dashboard pagesapp/(god)/*one folder per panel
Dashboard chromecomponents/shellsidebar.tsx, topbar.tsx, command-palette.tsx
Domain UIcomponents/{fleet,platform,licenses,recovery,slots,…}
Agent endpointsapp/api/v1/*+ next.config.ts rewrite
Request validationlib/apischemas.ts, validate.ts
Data accesslib/data.ts + lib/*/store.tsgated by lib/db.ts (useDb)
Jobs / deploylib/platformjobs.ts, reflect.ts, signing.ts
Auth / RBAC / auditlib/{session,rbac,actions}.ts, lib/{auth,audit}
Migrationslib/migrations, instrumentation.tsrunner.ts, core.ts, list.ts
Agentagent/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 /slots panel, migration 0002).
  • Operator runbooks (deploys, migrations, agent, API, ops): the hostssh skill referencesdeploy-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.