Skip to content

Architecture

Overview

flowchart TB
    subgraph Browser
        UI[SvelteKit SPA]
    end
    subgraph Host["Linux host"]
        NG[nginx :443]
        subgraph DP["Deployer (systemd, 127.0.0.1:8150)"]
            API[httpapi<br/>REST + SSE]
            ENG[deploy.Engine<br/>runs, undo, rollback]
            HOST[host.Real<br/>whitelisted operations]
            AK[authentik.Client]
            DNS[ionos.Client]
            CAT[catalog + ssodetect]
            AL[autologin]
        end
        PG[(deployer-db<br/>PostgreSQL 127.0.0.1:5450)]
        STACKS[/srv/stacks/&lt;app&gt;/]
        DOCKER[Docker Engine]
        CB[certbot]
    end
    AUTH[Authentik]
    IONOS[IONOS DNS API]
    HUB[Docker Hub / registries / GitHub]
    UI -->|HTTPS| NG --> API
    API --> ENG --> HOST
    ENG --> AK --> AUTH
    ENG --> DNS --> IONOS
    API --> CAT --> HUB
    HOST --> STACKS
    HOST -->|docker compose| DOCKER
    HOST -->|nginx -t, reload| NG
    HOST --> CB
    API --> PG
    NG -->|/__deployer/autologin| AL
  • One process, one binary. The Go server embeds the built SvelteKit app (internal/webui) and serves it for every path that is not /api/….
  • Host service. It must write /etc/nginx, run docker compose, nginx -t, systemctl reload nginx and certbot. It listens on loopback only; nginx terminates TLS.
  • No shell. Every server action goes through host.Real, a fixed set of operations with validated arguments (app names, domains, paths, compose sub-commands are checked against allow-lists). Commands are executed with exec and argument vectors, never through a shell.
  • Files are the truth. What runs is what is on disk: /srv/stacks/<name>/compose.yaml, its secrets/, the nginx site. The database records drafts, history and what Deployer created (DNS record id, certificate, Authentik objects) so it can update or remove exactly that.

Code map

api/
  cmd/deployer/        main: config, master key, database, migrations, engine wiring, HTTP server
  cmd/ssolab/          lab tool: throwaway Authentik user/group, cleanup (staging only)
  internal/
    config/            environment variables → Config
    db/                pgx pool, embedded SQL migrations (001_init, 002_link_sso)
    secret/            AES-256-GCM box (master key) for secrets at rest
    auth/              Argon2id passwords, sessions, CSRF, TOTP, OIDC sign-in to Deployer, rate limiter
    httpapi/           chi router, handlers, roles, audit, SSE run events, auto-login endpoint
    deploy/            Engine: drafts, deploy pipeline, updates, delete, runs and logs (Store), SSO sync,
                       account changes, sign-in checks
    catalog/           templates.json (embedded), Build (template → compose model), Docker Hub client,
                       SSO recipes, placeholders, saved recipes
    compose/           compose model ⇄ YAML (Render/Parse), secrets files, Lint
    nginxconf/         nginx site model ⇄ text (Render/Parse), validation
    host/              Host interface; Real (the server) and Fake (tests, dev)
    ionos/             IONOS DNS client + fake
    authentik/         Authentik API client (groups, users, proxy/OIDC/SAML providers, bindings) + fake
    ssodetect/         single sign-on evidence: recipes, known apps, Authentik guides, image docs
    autologin/         server-side sign-in: HTML login forms, Uptime Kuma adapter
    webui/             embedded build of the web interface
web/                   SvelteKit (Svelte 5, Tailwind), built as a static SPA
  src/routes/          dashboard, new (wizard), apps/[name], runs, system, settings, admin, account, login, setup
  src/lib/components/  ImagePicker, ComposeEditor/Simple, NginxEditor, GroupPicker, AccountLink, RunLog, …
deploy/                systemd unit, database compose file, nginx site for Deployer itself
scripts/               build, deploy, dev stack, API end-to-end checks, UI checks, real-login lab, guides index

The deploy pipeline

One server-changing run at a time (Engine.mu). Each step registers its undo before moving on; any error runs the undo list in reverse order and marks the run rolled-back.

flowchart TD
    A[Checking<br/>name, DNS, vhost, container, port,<br/>docker compose config, nginx -t HTTP + HTTPS] --> B[DNS A record]
    B --> C[Write stack folder<br/>compose.yaml, secrets/ 400, data/, README]
    C --> D[Authentik<br/>proxy provider + app + group bindings + outpost;<br/>OIDC client / SAML provider when chosen]
    D --> E[Pull images]
    E --> F[Start containers<br/>wait healthy + HTTP probe]
    F --> G[Recipe init commands]
    G --> H[Temporary HTTP site + reload]
    H --> I[Wait for the authoritative DNS]
    I --> J[certbot certonly --nginx]
    J --> K[Final HTTPS site + reload]
    K --> L[Verify https://name.domain through 127.0.0.1:443 with SNI]
    L --> M[Checking the sign-in]
Step Undo
DNS record delete the record (only if Deployer created it)
Stack folder remove the folder
Authentik remove application, providers, bindings, outpost entry
Containers docker compose down --remove-orphans
nginx site remove the site, reload
Certificate certbot delete (only if Deployer issued it)

Notes:

  • The Authentik step happens before the containers start: OIDC apps read the issuer at startup.
  • nginx validation never touches the live configuration: the candidate site is tested with a temporary copy of nginx.conf and every other enabled site; before the certificate exists, a throwaway self-signed certificate stands in for the Let's Encrypt paths.
  • certbot certonly --nginx needs the HTTP site first; Deployer then writes the final HTTPS site itself (certbot never edits it). Renewals use certbot's own timer and deploy hook.
  • A restart of Deployer during a run marks it failed ("interrupted") on the next start.

Updates and account changes

  • Compose update: validate → back up the current files to backups/<file>.bak-<time> → write → pull → up -d → wait healthy → on failure restore the backup and up -d again.
  • nginx update: isolated nginx -t → install → reload → verify → restore on failure.
  • Groups: Authentik bindings replaced in place (no redeploy).
  • Account method (SetAccount): read the files on disk → remove the previous settings → apply the new ones → create/remove the OIDC client or SAML provider → re-render nginx (user headers, Basic credentials, auto-login location, open ACS path) → write → restart → health check → recipe init → sign-in check → record. Undone on failure.

Data model

erDiagram
    users ||--o{ sessions : has
    users ||--o{ audit_log : writes
    users ||--o{ apps : creates
    apps ||--o{ runs : has
    runs ||--o{ run_logs : has
    settings {
        text key PK
        jsonb value
    }
    apps {
        text name UK
        text fqdn UK
        text image
        int host_port
        text subnet
        text status
        jsonb auth
        jsonb created
        jsonb draft
        text compose
        text nginx
    }
    runs {
        text kind
        text status
        text error
    }
Table Content
users email, name, Argon2id hash, role, TOTP secret (sealed), SSO subject, disabled
sessions SHA-256 of the session token, MFA-pending flag, expiry (24 h sliding), IP, user agent
audit_log every acting request: who, what, details, IP
settings Deployer's OIDC sign-in, the Authentik connection (token sealed), saved SSO recipes
apps name, domain, image, port, subnet, status, access (auth), what was created (created), the full draft model (compose model, secrets, nginx settings, account choice and state), the deployed texts
runs, run_logs every operation and each log line (step, level, text)

Run kinds: deploy, update-compose, update-nginx, restart, stop, start, delete, link-sso.

Live logs

Each log line is written to run_logs before it is broadcast to subscribers, so a client that connects mid-run reads the history from the database and then follows the stream (GET /api/runs/<id>/events, Server-Sent Events, events line and end).

Conventions written into every app

What Convention
Domain <name>.<DEPLOYER_DOMAIN> → DEPLOYER_SERVER_IP
Files <stacks>/<name>/: compose.yaml, secrets/*.env (mode 400, single-quoted values), data/, backups/, README.md
Ports 127.0.0.1:<8200-8999> only: nginx is the only way in
Network one private network per app, 10.250.N.0/24 with explicit IPAM
Containers restart: unless-stopped, no-new-privileges, json-file logs 10 MB × 3, labels stack.group/domain/managed-by
HTTPS Let's Encrypt via certbot, HSTS, nosniff, referrer policy
nginx site written by Deployer only, mode 600 (may hold credentials), a # deployer: settings line to read it back