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:

  1. Clones the app's bare git repo at the recorded commit SHA into a tmp dir.
  2. Runs docker build via Docker.DotNet, tagging localhost:5000/<org>/<app>:<sha>.
  3. Pushes to the local registry.
  4. Updates Deployment.StatusBuilt and stores ImageRef.

Build output lines are streamed to a BuildLog table the dashboard tails.

Paas.Orchestrator — reconciler

Runs a loop every few seconds:

  1. For each App with CurrentRelease, reads desired replicas, image, env, resources.
  2. Compares to running ContainerInstance rows + actual Docker state.
  3. Computes diff → starts new containers, stops old, in rolling fashion: start one new, wait for health, then drop one old.
  4. Updates ContainerInstance rows with status + container id.
  5. Notifies ProxyController via a ProxyDirty flag 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:

  1. Reads all Domains for the App (apex + custom).
  2. Reads all healthy ContainerInstances for the App's current Release.
  3. Renders an nginx vhost from a Razor-style template into /etc/nginx/conf.d/<domain>.conf with an upstream block listing container IPs:ports.
  4. Runs nginx -t. If OK, sends SIGHUP to nginx via the Docker API.
  5. Clears ProxyDirty.

Paas.CertManager — ACME / Let's Encrypt

Watches Domain rows where TlsStatus = Requested. For each:

  1. Calls Let's Encrypt via Certes, requests an HTTP-01 challenge.
  2. Writes the challenge token to a shared volume nginx already serves at /.well-known/acme-challenge/.
  3. Awaits validation, fetches the cert, writes /etc/nginx/certs/<domain>.pem + .key.
  4. Updates Domain.TlsStatusActive, sets renewal-not-before.
  5. Notifies ProxyController to swap the vhost into HTTPS mode.

⚠️ CertManager currently 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. See src/Paas.CertManager/AcmeClient.cs for 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 run for hot reload,
  • runs the Next.js dashboard via next dev instead 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. Holds CurrentReleaseId (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 OrgScope filter 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 with chmod 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.1 and 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 kaniko to drop the privilege.