Architecture & Conventions
Service topology
Both deployment targets (Docker Compose and Helm) run the same logical services. The full stack has 11 containers (10 running, 1 ephemeral):
| Container | Role | Image | Notes |
|---|---|---|---|
srdp-postgres |
Shared platform database | postgres:17-alpine |
Hosts databases for Zitadel and Dagster |
srdp-traefik |
Reverse proxy, TLS termination | traefik:v3.5.3 |
|
srdp-zitadel-init |
Database schema bootstrap | ghcr.io/zitadel/zitadel:v4.2.2 |
Runs once then exits |
srdp-zitadel |
Identity provider (OIDC) | ghcr.io/zitadel/zitadel:v4.2.2 |
API, console, OIDC endpoints |
srdp-zitadel-login |
Hosted login UI | ghcr.io/zitadel/zitadel-login:v4.2.2 |
Separate Next.js app since Zitadel v4 |
srdp-oauth2-proxy |
Forward-auth middleware | quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 |
|
srdp-dagster-code |
User pipeline code (gRPC) | Built from projects/default-etl/Dockerfile |
Separate so pipeline code can update independently |
srdp-dagster-webserver |
Orchestration UI | Built from deploy/docker/dagster-webserver.Dockerfile |
No source code — connects to code server over gRPC |
srdp-dagster-daemon |
Schedule & sensor execution | Same image as webserver | Must be a separate process per Dagster's architecture |
srdp-marimo |
Reactive notebook app | Built from services/marimo/ |
|
srdp-quarto |
Static reporting site | Built from services/quarto/ |
Why so many containers? Zitadel requires a one-time init container and ships its login UI as a separate app since v4. Dagster requires three processes by design: the code server (isolates user code), the webserver (UI), and the daemon (runs schedules/sensors/backfills). These cannot be merged without breaking the tools' architecture.
Naming conventions
Containers
All containers use the prefix srdp-:
srdp-postgres
srdp-traefik
srdp-zitadel
srdp-zitadel-init
srdp-zitadel-login
srdp-oauth2-proxy
srdp-dagster-webserver
srdp-dagster-daemon
srdp-dagster-code
srdp-marimo
srdp-quarto
Every container has an explicit container_name in docker-compose.yml to prevent auto-generated names.
Networks
| Name | Purpose |
|---|---|
srdp-app |
Services exposed via Traefik (HTTP traffic) |
srdp-db |
Database access (PostgreSQL only) |
Volumes
| Name | Purpose |
|---|---|
srdp-pgdata |
PostgreSQL data directory |
srdp-dagster-compute-logs |
Dagster compute log storage |
Domains
Local development uses *.local.dev with mkcert certificates:
| Domain | Service |
|---|---|
auth.local.dev |
Zitadel |
dagster.local.dev |
Dagster webserver |
marimo.local.dev |
Marimo |
quarto.local.dev |
Quarto |
Databases
One shared PostgreSQL instance. Each service gets its own database and user, created by deploy/docker/initdb/01-create-databases.sql:
| Database | User | Used by |
|---|---|---|
zitadel |
zitadel |
Zitadel identity provider |
dagster |
dagster |
Dagster run/event storage |
Component configuration
PostgreSQL
Shared across all services. The initdb/ directory runs SQL scripts on first boot to create per-service databases. Adding a new service database means adding a CREATE USER / CREATE DATABASE pair to the init script.
Traefik
- Docker provider discovers services via container labels.
- Static routes for Zitadel and Zitadel Login are defined in
config/traefik/traefik.yml(file provider) because they requireinsecureSkipVerifyfor the self-signed backend. - All other services are routed via Docker labels on their containers.
- TLS: mkcert certificates locally (via
tls.stores.default), Let's Encrypt in production (via ACME cert resolver).
Zitadel
- Runs with TLS enabled (
ZITADEL_TLS_ENABLED: true) using the same mkcert certificates. zitadel-initruns once to bootstrap the database schema, then exits.zitadel-loginis the hosted Next.js login UI, connected via a personal access token generated during init.
OAuth2-Proxy
- Configured as a Traefik forward-auth middleware (
zitadel-auth). - Protects all app services (Marimo, Quarto, Dagster) — requests are redirected to Zitadel for OIDC login.
- The
dagster.local.devdomain is included in the oauth2-proxy router rule alongside the other app domains.
Dagster
Three containers, two images:
- dagster-webserver and dagster-daemon share an image (
dagster-webserver.Dockerfile) that installs only the[infra]extra — no source code. They connect to the code server over gRPC. - dagster-code uses the project Dockerfile (
projects/default-etl/Dockerfile) which installs the fullsrdppackage plus project code. It runsdagster code-server startto expose definitions over gRPC. - The Helm chart uses
dagsterApiGrpcArgsto configure the same gRPC server — the Dagster Helm chart manages the command internally, socode-servervsapi grpconly applies to the Docker Compose setup. dagster.yamlconfigures PostgreSQL storage and the run launcher (DefaultRunLauncherlocally,K8sRunLauncherin production).workspace.yamltells the webserver/daemon where to find the code server (srdp-dagster-code:3030).
Marimo & Quarto
Stateless app containers. No database dependency. Routed through Traefik with the same OAuth2-Proxy auth chain as Dagster.
Directory layout
config/
dagster/
dagster.yaml # Dagster storage & run launcher config
workspace.yaml # Code server location
traefik/
traefik.yml # Traefik static configuration
deploy/
docker/
docker-compose.yml # Local development stack
docker-compose.override.yml # TLS cert mounts
dagster-webserver.Dockerfile
initdb/ # PostgreSQL init scripts
certs/ # mkcert certificates (gitignored)
kubernetes/
srdp-chart/ # Helm umbrella chart
projects/
default-etl/ # Example Dagster project
Dockerfile
src/etl/
services/
marimo/
quarto/
src/
srdp/ # Core platform library