DinD HostingManual
Overview/Manual/Run the platform
CHAPTER 16

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+
outside Browsers*.alex.example.com REST clients · CIBearer JWT / dt_ token AI agentsClaude Code · MCP Host ingress reverse proxy ornftables on the hostyour decision → 10.10.10.10 dind-hostingmain DinD · privileged · bridge “hosting” · 10.10.10.10 · no published host ports platform-net · 172.28.0.0/16 · static IPs Traefik:80 :443 · ACME172.28.0.3 management:8900 UI·API·MCP172.28.0.2 platform-pgstate · metrics172.28.0.4 MinIOS3 · backups172.28.0.5 projects-alex172.28.0.64 blogstatic:10000 shopnode 24:10001 dbshared DB daemon172.28.0.10 postgresshop-main:20000 mysqlblog-wp:20001 projects-bob172.28.0.65 apipython 3.12:10000 sitephp 8.5:10001 db-aliceoptional · dedicated postgresanalytics:20000 /platform-data/sockets/ main.sock · db.sock · projects-alex.sock · … data/ on the host bind-mounted into the main DinD: main-docker · platform/{db,minio,deploys, traefik,secrets,sockets} · dind/ legend HTTP route chosen by Host Docker API over unix sockets user daemon (apps) database daemon the one host-level container
The complete picture: one host-level container, one platform network, and a daemon per user.

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#

Traefik172.28.0.3 · :80 :443route: Host(blog…)projects-alexnetwork namespace = 172.28.0.64app-blog0.0.0.0:10000app-shop0.0.0.0:10001Containers run with --network host, which inside adaemon means the daemon container’s own network:no port publishing, no NAT chain, ports unique per daemon.172.28.0.64:10000db daemon172.28.0.10postgres :20000 · mysql :20001MinIO172.28.0.5S3 :9000 · console :9001DB_MAIN_HOST/PORTS3_ENDPOINTEvery long-lived container has a static address on platform-net (172.28.0.0/16); user daemons are allocated from 172.28.0.64–127 (database daemons 172.28.1.64–127).App ports come from 10000–19999 per app daemon, database ports from 20000–29999 per database daemon; a counter never reuses a number during a daemon’s life.
User workloads run with host networking inside their own daemon, so each app binds directly on a stable address.
  • An app binds 0.0.0.0:<port> on its daemon's address. Traefik routes Host(blog.alex…) straight to http://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#

  1. Secretsgenerate any that are missing in /secrets
  2. Databasereach platform-pg, run Alembic migrations
  3. Reconcilenetwork, platform containers, every user's daemon
  4. Traefikrebuild from stored ACME settings, recreate on drift
  5. Workersmetrics sampler, domain watchdog, backup pruner
  6. 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#

  1. Fetch — git clone --depth 1 [--branch ref] or an extracted archive (traversal- and size-guarded) into /deploys/build/<user>/<project>/<version>/.
  2. Manifest — parse platform.yaml (strict), detect the runtime, resolve the version (manifest → hint → default).
  3. 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.
  4. 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.
  5. 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.