Architecture & deployment topology
How KHUB's containers, storage volumes, and public ingress fit together in production.
Process topology#
A production KHUB instance is a fixed set of containers (see docker-compose.yml for the reference topology):
| Service | Exposed as | Notes |
|---|---|---|
caddy | Public HTTPS (80/443) | Sole public ingress. Automatic TLS via ACME when given a real domain. Routes by path — see the table below. |
web | Internal only | Next.js server in standalone output mode; renders pages and proxies its own /api/* fetches to api. |
api | Internal only | The NestJS monolith. Stateless with respect to HTTP (state lives in Postgres/Redis/disk volumes), so it can be scaled horizontally behind Caddy if needed. |
runner | Internal only, no inbound traffic | Long-polls Redis for CI jobs. Scale this independently of api to add CI capacity; each replica registers itself as a distinct Runner row keyed by RUNNER_NAME. |
ssh-git | Public TCP (22, or a configured alternate port) | Only speaks the Git SSH protocol; not part of the HTTPS surface. |
mcp | Internal only (proxied at /mcp) | See MCP integration. |
postgres | Internal only | Single source of truth for all relational data. |
redis | Internal only | CI job queue (BullMQ) only — not used as a cache or session store. |
Caddy path routing#
/v2* → oci registry (api, Docker Distribution v2)
/mcp* → mcp bridge
/*.git/info/refs, /*/git-* → api (Git Smart HTTP)
/api* → api (REST, WebSocket upgrade for /api/socket.io)
/* → web (Next.js)WebSocket upgrades for chat/voice signaling are negotiated over /api/socket.io, so they pass through the same /api* rule — there is no separate WebSocket ingress to configure.
Storage volumes#
State that must survive a redeploy lives in named volumes, not container filesystems:
| Volume | Contents |
|---|---|
postgres_data | The PostgreSQL data directory. |
git_data | Every bare repository (<repositoryId>.git), including hooks. |
ci_data | CI job logs and cached job workspaces (see CI/CD pipelines). |
lfs_data | Git LFS object storage, content-addressed by OID. |
packages_data | npm-compatible package tarballs. |
oci_data | Container image blobs and manifests (content-addressed by digest). |
release_assets, uploads_data | Release download assets and general file uploads (avatars, banners, attachments). |
A complete backup is: a pg_dump of postgres_data, plus a file-level copy of every other volume. There is no separate metadata store to reconcile — Postgres is authoritative for everything except the byte content addressed by the volumes above.
Why one process for so much#
apps/api deliberately hosts the REST API, the Git protocol endpoints, the OCI registry, the npm registry, SCIM, and the chat WebSocket gateway in a single NestJS application rather than as separate microservices. They share one Prisma client, one permission-resolution code path, and one audit log, which is what makes "the same role change instantly affects Git access, package publish rights, and API tokens" true without a synchronization step between services. The only components pulled out of that process are the ones with a genuinely different execution model: runner (needs Docker socket access and untrusted-code isolation), ssh-git (a different wire protocol entirely), and mcp (a translation layer with its own protocol, kept stateless and disposable).
