Architecture
This document describes how Paas is structured, the responsibilities of each
service, and the data flow for a deployment from git push to live traffic.
High-level diagram
┌──────────────────────────────────────────────┐
│ Paas host │
developer ── git ──▶│ ┌─────────────┐ ┌──────────────────────┐ │
│ │ git-server │───▶│ Builder │ │
│ │ (sshd) │ │ (clone+docker build)│ │
│ └─────────────┘ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ │
│ │ Local Registry│ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────┐ ┌──────────────▼─────────────┐ │
browser ── web ──▶│ │ nginx│◀───│ Orchestrator │ │
│ └──┬───┘ │ (LocalDockerDriver) │ │
│ │ └──────────────┬─────────────┘ │
│ │ │ │
│ │ ▼ │
│ │ ┌────────────────┐ │
│ └─────────────▶│ User containers│ │
│ └────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Paas.Api │◀──▶│ Postgres │ │
│ │ (REST + JWT) │ │ (control) │ │
│ └──────┬───────┘ └──────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ ┌────────────────┐ │
│ │ ProxyController │ │ CertManager │ │
│ │ (writes nginx │ │ (ACME) │ │
│ │ conf.d & reload)│ │ │ │
│ └──────────────────┘ └────────────────┘ │
└──────────────────────────────────────────────┘
Services
Paas.Api — control-plane HTTP API
ASP.NET Core minimal API. Browser callers authenticate via the BFF cookie set during the OIDC code flow; CLI/external clients can use bearer access tokens issued by the embedded OpenIddict server. Exposes:
/auth/login,/auth/logout,/auth/me— BFF endpoints used by the dashboard/connect/authorize,/connect/token,/connect/userinfo,/connect/logout,/.well-known/openid-configuration— OpenIddict OIDC server endpoints/Account/Login,/Account/Logout— Razor-rendered credential UI/me/keys— SSH key registration/orgs— create/list orgs, manage members/orgs/{org}/apps— CRUD for apps/orgs/{org}/apps/{app}/deployments— list deployments, trigger redeploy/orgs/{org}/apps/{app}/env— env vars (encrypted at rest, redacted on read)/orgs/{org}/apps/{app}/domains— custom domains, request TLS/orgs/{org}/apps/{app}/databases— provision/list/delete managed Postgres/orgs/{org}/apps/{app}/scale— set replicas + resource limits/hubs/logs— SignalR hub for live container logs/internal/git/post-receive— webhook called by the git server
The API is the only writer of business intent (the "desired state"). All side-effecting workers — Builder, Orchestrator, ProxyController, CertManager — read intent from Postgres and converge actual state toward it.
Paas.Builder — build worker
Polls Deployment rows where Status = Queued. For each:
- Clones the app's bare git repo at the recorded commit SHA into a tmp dir.
- Runs
docker buildviaDocker.DotNet, tagginglocalhost:5000/<org>/<app>:<sha>. - Pushes to the local registry.
- Updates
Deployment.Status→Builtand storesImageRef.
Build output lines are streamed to a BuildLog table the dashboard tails.
Paas.Orchestrator — reconciler
Runs a loop every few seconds:
- For each
AppwithCurrentRelease, reads desiredreplicas,image,env,resources. - Compares to running
ContainerInstancerows + actual Docker state. - Computes diff → starts new containers, stops old, in rolling fashion: start one new, wait for health, then drop one old.
- Updates
ContainerInstancerows with status + container id. - Notifies
ProxyControllervia aProxyDirtyflag on the App.
The actual docker run/docker stop/docker rm calls are behind the
IComputeDriver interface; the v1 implementation is LocalDockerDriver.
Paas.ProxyController — nginx config writer
When any App is ProxyDirty:
- Reads all
Domains for the App (apex + custom). - Reads all healthy
ContainerInstances for the App's current Release. - Renders an nginx vhost from a Razor-style template into
/etc/nginx/conf.d/<domain>.confwith anupstreamblock listing container IPs:ports. - Runs
nginx -t. If OK, sendsSIGHUPto nginx via the Docker API. - Clears
ProxyDirty.
Paas.CertManager — ACME / Let's Encrypt
Watches Domain rows where TlsStatus = Requested. For each:
- Calls Let's Encrypt via
Certes, requests an HTTP-01 challenge. - Writes the challenge token to a shared volume nginx already serves at
/.well-known/acme-challenge/. - Awaits validation, fetches the cert, writes
/etc/nginx/certs/<domain>.pem+.key. - Updates
Domain.TlsStatus→Active, sets renewal-not-before. - Notifies ProxyController to swap the vhost into HTTPS mode.
⚠️
CertManagercurrently runs against the Let's Encrypt staging endpoint by default to avoid getting rate-limited. Switch the configuration flag once you've validated end-to-end on a real domain. Seesrc/Paas.CertManager/AcmeClient.csfor the TODO sites.
Orchestration: docker-compose
There is no Aspire AppHost. Both development and production use the same
docker-compose.yml at the repo root. docker-compose.override.yml
adds dev-only conveniences:
- mounts the source tree into each service container,
- runs each .NET service via
dotnet watch runfor hot reload, - runs the Next.js dashboard via
next devinstead of the static export, - exposes Postgres, the local registry, and the API on host ports for inspection,
- swaps the CertManager into Let's Encrypt staging mode.
This means docker compose up is the only command needed to bring the whole
system up locally; docker compose -f docker-compose.yml up -d (no override)
is the production form.
Data model
Core entities (see src/Paas.Domain/Entities/):
User— global identity, email/password, ssh keys.Organization— tenant.Membership—(User, Organization, Role)where Role ∈ {Owner, Admin, Member}.App—(Organization, Slug)unique. HoldsCurrentReleaseId(nullable).GitRepo— 1:1 with App. Stores ssh path on git server.Deployment— immutable.(App, CommitSha, Status, ImageRef, BuildLog). Status:Queued → Building → Built → Releasing → Live | Failed | Superseded.Release— promoted Deployment + frozen env snapshot + scale spec. The Orchestrator reconciles to the App's current Release.EnvVar—(App, Key, EncryptedValue). Pulled into a Release on promote.Domain—(App, Hostname, IsApex, TlsStatus, CertNotAfter).ManagedDatabase—(App, Name, EngineVersion, ContainerId, EncryptedDsn).ServiceSpec—(Release, Replicas, CpuMillis, MemoryMb).ContainerInstance—(Release, Index, NodeId, ContainerId, Status, Ip, Port).Node— host that runs containers. Single row in v1; multi later.AuditEvent— append-only; rendered in dashboard.
Deploy flow (end to end)
developer git-server API Builder Orchestrator ProxyController
│ │ │ │ │ │
│── git push ───────▶│ │ │ │ │
│ │── webhook ───▶│ │ │ │
│ │ │ create Deployment(Queued) │ │
│ │ │ │ │ │
│ │ │ │ poll → claim │ │
│ │ │ │── git clone │ │
│ │ │ │── docker build │ │
│ │ │ │── docker push │ │
│ │ │ Deployment(Built, ImageRef) │ │
│ │ │ promote → Release │ │
│ │ │ │ │ reconcile loop │
│ │ │ │ │── start N new │
│ │ │ │ │ containers │
│ │ │ │ │── health check │
│ │ │ │ │── stop old │
│ │ │ │ │── set ProxyDirty
│ │ │ │ │ │── render conf.d
│ │ │ │ │ │── nginx -t
│ │ │ │ │ │── nginx -s reload
│ │ │ │ │ │
└────────────── visit https://<app>.example.com ──────────────────────▶ nginx ──▶ container
Why the indirection (ServiceSpec / ContainerInstance / Node)?
So that LocalDockerDriver (v1) can be replaced by SwarmDriver or
KubernetesDriver later without changing API or Orchestrator core. The
Orchestrator computes desired ContainerInstance rows; the driver carries
out the verbs (Run, Stop, Inspect).
Security model
- All API endpoints (except
/auth/*and/internal/*from localhost) require a JWT. - An
OrgScopefilter resolves{org}from route, looks up the authenticated user's membership, and rejects unless the role meets the endpoint's minimum. - Env vars and DB DSNs are encrypted with AES-GCM via a host master key in
/etc/paas/master.key(created by the installer withchmod 600). - Git server enforces "the SSH key must belong to a User who is a member of
the org that owns the repo at path
/<org>/<app>.git." Path-based authz — there is no shared filesystem leak. - Local registry is bound to
127.0.0.1and only the Builder/Orchestrator containers can reach it. - Builder runs Docker builds via the host daemon (privileged). v2 should
switch to BuildKit-in-rootless or
kanikoto drop the privilege.