Architecture
Why one privileged container holds a whole PaaS, how its parts find each other, and which decisions shaped it. Read this when you operate the platform or want to know what happens behind a button.
One container, many daemons#
The host runs one privileged docker:dind container, dind-hosting, with its data directories bind-mounted from the host. Inside it, a private network platform-net (172.28.0.0/16) hosts:
| Container | Role | Address |
|---|---|---|
management |
FastAPI service: console (SPA), REST API, MCP server, reconciler, workers | 172.28.0.2 · :8900 |
traefik |
load balancer, TLS, ACME | 172.28.0.3 · :80 :443 |
platform-pg |
state, audit, metrics (PostgreSQL 16) | 172.28.0.4 |
minio |
S3 object store: backups, logs, per-user buckets | 172.28.0.5 · :9000 :9001 |
db |
shared database daemon (another docker:dind) |
172.28.0.10 |
projects-<alias> |
one app daemon per user | 172.28.0.64+ |
db-<alias> |
optional dedicated database daemon | 172.28.1.64+ |
Why everything in one container? One host-level unit to operate (one container, one Compose file, one set of bind mounts, one address), and the management container and all child daemons become siblings on one network. The host exposure is exactly one address on one bridge network — no published ports.
Talking to every daemon: the socket directory#
Each daemon — main, db, every projects-<alias> — creates its API socket in one shared directory (/platform-data/sockets, bind-mounted from the host). Management mounts the directory and talks to all daemons through the same unix-socket client. It never uses a TCP port, and the main daemon has no TCP listener at all: an unauthenticated Docker API must not be reachable from the hosting network.
| Daemon | Socket |
|---|---|
| main | main.sock (plus /var/run/docker.sock for docker exec dind-hosting … from the host) |
| shared database | db.sock |
| user apps | projects-<alias>.sock |
| user databases | db-<alias>.sock |
Networking: host mode inside a daemon#
- An app binds
0.0.0.0:<port>on its daemon's address. Traefik routesHost(blog.alex…)straight tohttp://172.28.0.64:10000. - Cross-daemon access is plain layer 3: an app reaches its database at the database daemon's address, and MinIO at
172.28.0.5:9000. The platform injects those addresses as environment variables. - Port uniqueness is needed only within one daemon, so allocators are per daemon, DB-backed and never reuse numbers during a daemon's life (no clash with stale connections).
Management: boot and reconcile#
- Secretsgenerate any that are missing in
/secrets - Databasereach platform-pg, run Alembic migrations
- Reconcilenetwork, platform containers, every user's daemon
- Traefikrebuild from stored ACME settings, recreate on drift
- Workersmetrics sampler, domain watchdog, backup pruner
- Serveopen the API, console and MCP
The reconciler creates what is missing and starts what is stopped; it never destroys user data. Only the stateless Traefik container is recreated when its desired spec changes — the spec is hashed into a label and compared on every boot.
The deploy flow, in detail#
- Fetch —
git clone --depth 1 [--branch ref]or an extracted archive (traversal- and size-guarded) into/deploys/build/<user>/<project>/<version>/. - Manifest — parse
platform.yaml(strict), detect the runtime, resolve the version (manifest → hint → default). - Build — render a Dockerfile, stream the context over the daemon's socket (never bind-mounted into a child daemon, so sources never cross users) and build
platform/<slug>:<version>-<sha8>plus:latest. - Run — stop and remove the old container; start the new one with
--network host, the allocated port, the composed environment, volumes and limits,restart=unless-stopped. - Record — status, manifest and the full log (to MinIO), an audit row; write the Traefik route after the pre-check.
The pipeline is an in-process background task. If management restarts mid-build, the interrupted deployment is marked failed (interrupted by management restart) and you simply deploy again.
Security model in brief#
| Area | Mechanism |
|---|---|
| Host exposure | one address on a bridge network; no published ports; no TCP Docker API on the main daemon |
| Authentication | argon2id password hashes; JWT (8 h) and long-lived dt_… tokens stored as hashes |
| Authorisation | admin / user; ownership enforced in the service layer; foreign objects answer not found |
| Tenant isolation | a separate app daemon per user; sources streamed per deploy; separate database containers with their own credentials |
| Secrets | generated into /secrets (0600); stored credentials encrypted with a Fernet key; secret variables write-only |
| Audit | audit_log for every mutation, query_log for every SQL console statement |
| TLS | ACME HTTP-01 (or a local dev CA) after a pre-check that proves the name leads back to the platform |
The shared database daemon is an accepted trade-off: databases are isolated by container boundary and per-database credentials rather than by daemon. A dedicated db-<alias> daemon removes that sharing for users who need it.
Data model#
| Table | Holds |
|---|---|
users, api_tokens |
accounts, roles, aliases; token hashes |
dind_instances, port_allocations |
every daemon (kind, address, socket) and every allocated port |
projects, project_domains |
projects and their domains with pre-check and certificate status |
deployments, apps |
every deployment with its resolved manifest; the current app per project |
project_env_vars |
project variables (secrets as Fernet tokens) |
databases |
each database: engine, image, daemon, port, encrypted password, volume |
backups |
backup rows with MinIO key, size and sha256 |
metrics_samples, metrics_hourly |
15-second samples and hourly roll-ups |
audit_log, query_log |
the audit trail |
upload_tokens, settings |
single-use upload links; platform-wide settings |
Decisions that shaped it#
| Decision | Choice and reason |
|---|---|
| One main DinD holding everything | a single host-level unit and address; trivial data/ backup (ADR-001) |
| Unix sockets in a shared directory | one uniform client; no TCP Docker API anywhere reachable (ADR-002, ADR-040) |
| Bind-mounted data root for every daemon | overlay2 on ext4 beneath nested daemons works reliably (ADR-003) |
| Host networking inside daemons | the app is on the daemon's address; no inner port mapping (ADR-006) |
| Pre-check before certificates | never ask a CA for a name that does not lead back to us (ADR-009) |
| Build contexts streamed, not mounted | sources never cross users (ADR-011) |
| One web app per project | Vercel-like "project = one deployable" (ADR-013) |
| Dev TLS mode next to ACME | full TLS end-to-end tests without public DNS (ADR-014) |
| Admin-managed users | no self-registration in v1 (ADR-017) |
| Platform-managed variables | encrypted, write-only secrets; recreate rebuilds the environment (ADR-045) |
| Single-use upload links | big archives bypass MCP's 4 MiB limit safely (ADR-046) |
| Runtime versions in the manifest | short versions follow the newest release; pinned versions stay put (ADR-047) |
| A database keeps its image | no accidental major upgrade of a data directory (ADR-049) |
Not in this version#
Multi-host or Kubernetes deployment · horizontal scaling of the management service · per-project (instead of per-user) isolation · a registry shared between daemons · rollback UI beyond redeploying an older source · SSO and self-registration · direct external database ports · scheduled backups · Prometheus exporters and alerting.