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/<app>/]
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, rundocker compose,nginx -t,systemctl reload nginxandcertbot. 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 withexecand argument vectors, never through a shell. - Files are the truth. What runs is what is on disk:
/srv/stacks/<name>/compose.yaml, itssecrets/, 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.confand every other enabled site; before the certificate exists, a throwaway self-signed certificate stands in for the Let's Encrypt paths. certbot certonly --nginxneeds 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 andup -dagain. - 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 |