On this page
Deployment guide
This guide describes how CommonLayer is deployed: the components and what they need, the configuration a production deployment must provide, the secrets, the network flows, and the steps from an empty cluster to a running platform with its first administrator.
Status (WP-70g, 2026-11-10). Verified in the build sandbox: the platform images
(backend, worker with cosign, Trivy and Grype, web) built from the tree and scanned; the
whole platform in containers (make stack) with separate owner and runtime database
roles, per-process secrets, read-only root filesystems, health, readiness, a login and
the metrics endpoints; the chart's static tests (lint, kubeconform on Kubernetes 1.31 and
1.37, 63 helm-unittest tests, Trivy configuration scan); the publish pipeline with real
tools (publish drill); backup, restore and the restore drill. Not
verified here: an installation of the chart on a cluster (the kind smoke test runs only
in CI, infra.yml, where it passed: install with the migration hook, helm test,
upgrade, on one kind node with development-grade values, see the
implementation status),
CloudNativePG as the platform's database, anything on AWS, the arm64 images, keyless
signing, live Stripe.
Components
| Component | Process | Scales | Needs |
|---|---|---|---|
| API | commonlayer-api | horizontally (2 replicas by default in the chart) | PostgreSQL, object storage, cache (production), registry token signing key, SecretBox key |
| Worker | commonlayer-worker (image variant with cosign, Trivy and Grype) | horizontally | PostgreSQL, object storage, registry (promoter credentials), egress to source registries, GitHub, OSV and feeds, payment provider, SMTP, outbound webhooks; scanner cache volume |
| Scheduler | commonlayer-scheduler | 1 or 2 replicas (ticks are claimed in the database) | PostgreSQL, object storage |
| Migrations | clctl migrate, then clctl db check-role | once per release | the database owner (migrations, grants); the runtime role for the check |
| Portal | apps/web Next.js standalone server | horizontally | the API at API_INTERNAL_URL |
| Registry | CNCF Distribution registry:3 | horizontally on shared storage | S3-compatible bucket, the API as token realm, the certificate of the token signing key |
| PostgreSQL | external (managed service or the CloudNativePG-based chart) | — | version 16 or later; extensions citext, pg_trgm, btree_gist, pgcrypto |
| Valkey or Redis-compatible cache | external | — | used for shared rate-limit counters |
| Object storage | S3-compatible | — | buckets for platform artifacts, the registry, backups |
Routing: /api, /webhooks and /registry/token (and /registry/jwks.json) go to the
API; everything else on the portal host goes to the web server; the registry has its own
host. gRPC (port 50051, HTTP/2 cleartext inside the cluster) gets its own host when
exposed (chart: ingress.grpc, with the controller told that the backend speaks gRPC,
e.g. ingress-nginx nginx.ingress.kubernetes.io/backend-protocol: GRPC; most controllers
require TLS on the client side for gRPC). The ops port (9090: /healthz, /readyz,
/metrics) must be reachable only from monitoring.
Client addresses (ADR-0040).
Per-IP rate limits and audit entries use the client address the API resolves:
X-Forwarded-For is honoured only from peers in server.trusted_proxies, and the
rightmost address that is not a trusted proxy wins.
The web server's server-side API calls (personal pages) forward a client address only
when it is told that it sits behind a proxy that sets X-Forwarded-For from the real
client: environment BEHIND_TRUSTED_PROXY=true (chart: web.behindTrustedProxy, off by
default; set it together with the ingress or gateway). It then forwards the header as it
received it (it does not append its own peer). Off, it forwards no address and the API
attributes those calls to the web server's own address. So, in production: trust the
ingress controller's and the web server's pod addresses (chart: config.trustedProxies,
usually the pod CIDR), let the ingress controller set X-Forwarded-For from the client
(ingress-nginx does by default; use-forwarded-headers only behind another trusted
proxy), and turn web.behindTrustedProxy on. Never turn it on when the web Service can be
reached without passing that proxy: any client could then choose its own rate-limit
bucket. In production the ingress routes /api straight to the API. The Compose stack's
web image proxies /api itself (a Next.js rewrite), and that rewrite passes on whatever
X-Forwarded-For the browser sent without appending the real peer, so the stack trusts
no proxy by default: a forged header was honoured while the web container was trusted
(found in WP-70g; the API's debug line showed client_ip = the forged address).
| Setup | config.trustedProxies | web.behindTrustedProxy |
|---|---|---|
| Kubernetes with an ingress controller | pod CIDR | true |
Compose stack (make stack, nothing in front of the web container) | none (CL_STACK_TRUSTED_PROXIES, default []): every portal request counts against the web container's address | false (CL_STACK_WEB_BEHIND_TRUSTED_PROXY) |
| e2e workflow (each test sends its own address; test stack only) | the web container (["10.250.73.10/32"]) | true |
Development (make dev: next dev on the host) | 127.0.0.1/32, ::1/128 (.env): the dev server's rewrite passes a client's header on, acceptable on a developer machine | unset |
Check the configuration with the API's debug line
client address (COMMONLAYER__OBSERVABILITY__LOG_FILTER=info,commonlayer_auth::http::middleware=debug):
it logs the peer and the resolved client address of every request.
Browser origins. State-changing browser requests must come from server.public_url,
server.api_public_url or an origin in server.cors_allowed_origins (chart:
config.corsAllowedOrigins, https only in production). With the API on the portal's
host (the default), nothing else is needed.
Readiness. /readyz answers 503 when the database, object storage or (when
configured) the cache is unreachable. Object storage is checked per bucket: object_store
(the platform bucket, every process) and object_store_backups (the source backups
bucket, api and worker); a bucket that does not exist reports
storage.bucket_missing: object storage bucket "<name>" does not exist at <endpoint> with
details.bucket and details.endpoint (see
the runbook entry); database_role (the runtime connection is not
least privilege) and rate_limiter (decisions fall back to per-process limits) report
degraded with details and still answer 200, so the pods stay in rotation while the
DbPrivilegedConnection and
RateLimiterDegraded alerts fire.
Configuration
The processes read configuration from embedded defaults, an optional TOML file
(COMMONLAYER_CONFIG) and environment variables COMMONLAYER__<SECTION>__<KEY>. The chart
passes settings only as environment variables: common ones through its values
(config.*, email.*, objectStorage.*), any other non-secret setting through
config.extra, and secrets from existing Kubernetes Secrets. Check the effective
configuration with secrets masked:
clctl config check
What production requires
With environment = "production" the processes refuse to start unless (validation in
crates/commonlayer-config/src/sections/):
| Requirement | Setting |
|---|---|
| HTTPS public URLs | server.public_url, server.api_public_url |
| A shared cache for rate limits | cache.url |
| Rate limits enabled | rate_limits.enabled = true |
| An encryption key for webhook secrets and private reports (also required in staging) | secrets.encryption_key (base64 of 32 bytes) |
| Registry promoter password, events secret and token signing key | registry.promoter_password, registry.events_secret, registry.token.signing_key or signing_key_path |
| No insecure registries; cosign must require the transparency log | registry.insecure_registries = [], registry.cosign_require_transparency_log = true |
| Migrations run separately | database.migrate_on_start = false |
| HTTPS object storage and no in-memory storage | storage.s3.allow_http = false, storage.backend not memory |
| HTTPS outbound webhooks; TLS for SMTP | notifications.webhook.allow_http = false, email.smtp_tls not none |
| HTTPS feed and JWKS URLs | security.*_url, github.oidc.jwks_url |
| No simulated payments | payments.provider not simulated, payments.payouts.mode not simulated |
| Live payouts only with a legal entity and a real provider | payments.legal_entity.{name, jurisdiction, registration} when payments.payouts.mode = "live" |
Optional integrations
Each integration is off until configured, and the platform says so (*.not_configured
errors and UI notices) instead of pretending:
| Integration | Settings | Without it |
|---|---|---|
| Signature verification | registry.cosign_path (worker) | submissions stop with artifacts.verifier_not_configured |
| Scanners | security.trivy_path, security.grype_path, security.scanner_cache_dir, security.scanner_offline, security.osv_enabled | scanners are skipped; scan.fresh fails without at least one |
| NVD enrichment | security.nvd_api_key | EPSS, KEV and OSV only |
| GitHub App (onboarding, guardrails, source backups, managed access, webhooks, issue channel) | github.app_id, github.app_slug, github.app_private_key (or github.app_private_key_path, a PEM file), github.app_client_id, github.app_client_secret, github.webhook_secret; see the GitHub App | no repository can be onboarded or connected (github.not_configured); with projects.require_managed_repository on, no project can be proposed |
| GitHub Actions OIDC submissions | github.oidc.audience (the template requests commonlayer); issuer and JWKS URL default to GitHub's | CI submits with API tokens |
| GitHub OAuth, generic OIDC login | auth.github_oauth.*, auth.oidc_providers | email and password login only |
email.* | verification and reset mails are not sent (email.not_configured) | |
| Payments | payments.provider (stripe or bank_transfer) and its section | contributions are closed (payments.not_configured) |
| Tracing, error tracking | observability.otlp_endpoint, observability.sentry_dsn | logs and metrics only |
crates/commonlayer-config/config/default.toml lists every setting with its default.
Database roles
The platform uses two PostgreSQL roles (threat model O1/O2, ADR-0036):
| Role | Example | Attributes | Used by |
|---|---|---|---|
| owner | commonlayer_owner | LOGIN, owns the database and its schema; not needed to be a superuser | clctl migrate only (database.migration_url) |
| runtime | commonlayer_app | LOGIN and nothing else; not a member of the owner | api, worker, scheduler, every other clctl command (database.url) |
The ledger and audit protections are triggers; an owner can disable them, the runtime
role cannot. clctl migrate applies migrations as the owner and then grants the runtime
role exactly what it needs (derived from the catalog: DML on ordinary tables, INSERT
only on append-only tables, no DELETE on artifact digests, read-only migration
history); re-running converges the grants. clctl db check-role connects with
database.url and fails (exit 1) when that role is a superuser, owns objects, can
TRUNCATE, ALTER or disable triggers of protected tables, or rewrite append-only rows.
Procedure for a new installation:
- As a superuser:
CREATE ROLE commonlayer_owner LOGIN PASSWORD '…';CREATE DATABASE commonlayer OWNER commonlayer_owner;CREATE ROLE commonlayer_app LOGIN PASSWORD '…';(no other attribute). The extensions the migrations create (citext,pg_trgm,btree_gist,pgcrypto) are trusted extensions, which the database owner may create. - Owner URL →
database.migration_urlof the migration job only; runtime URL →database.urlof everything (chart:externalDatabase.migration.existingSecretandexternalDatabase.existingSecret, two separate Secrets). - Deploy: the migration job runs
clctl migrateandclctl db check-role; a failed check fails the Helm hook and the rollout stops before any pod of the new version runs.
Moving an existing installation that connects as the owner: create the runtime role,
run clctl migrate once with the owner as database.migration_url and the runtime URL
as database.url (it grants), verify with clctl db check-role, then switch the
processes to the runtime URL. Until then /readyz reports database_role: degraded and
the alert fires; the chart's migrate.checkRole: false lets an upgrade proceed meanwhile.
CloudNativePG: bootstrap the cluster with bootstrap.initdb.owner: commonlayer_owner (the
operator's <cluster>-app Secret then holds the owner's uri, for the migration job
only) and declare the runtime role under managed.roles with a password Secret; write the
runtime URL into the Secret the processes use. Managed services: the "master" user is the
owner (or creates it); never give it to the processes.
Evaluation installs of the chart (evaluation.postgresql) use the bundled database's
superuser as the owner and create the runtime role commonlayer_app at initialisation
(password in commonlayer-database, key runtime-password).
Development: scripts/dev/run-backend.sh creates commonlayer_app and runs the backend as
it; the containerised stack uses commonlayer_stack_owner (its migrate container) and
commonlayer_stack_app.
Secrets
| Secret | Used by | Notes |
|---|---|---|
| Runtime database URL | api, worker, scheduler, migration job (check-role) | use TLS (sslmode=verify-full) |
| Owner database URL | migration job only | never in a long-running process |
secrets.encryption_key (and secrets.previous_encryption_keys during a rotation) | api, worker | losing it means losing stored webhook secrets and private-report bodies; back it up separately from the database |
| Registry token signing key (ES256, PKCS#8) and its certificate | api and worker (key), registry (certificate) | the worker signs its own promoter tokens; rotation with a previous key, see the operations handbook |
| Registry promoter password | api (Basic logins at the token realm) | the worker does not need it while it holds the signing key |
| Registry events secret | api, registry | bearer secret of registry notifications |
| Object storage credentials | api, worker, scheduler, registry | or workload identity |
| Cache URL | api | contains credentials |
| Stripe secret key and webhook secret | api, worker | |
| GitHub App private key | api, worker | GitHub webhook secret, the App's client secret and the OAuth client secret: api only. Stewardship organization token: worker only |
| SMTP password, NVD API key | worker | |
| Sentry DSN | optional, any process |
Secrets per process
Each process validates and keeps only what it uses (Settings::load_for); a deployment
should not pass the others at all. The chart and the Compose stack follow this matrix
(✓ = receives it):
| Secret / setting | api | worker | scheduler | migration job |
|---|---|---|---|---|
database.url (runtime role) | ✓ | ✓ | ✓ | ✓ (target of the grants and of check-role) |
database.migration_url (owner) | — | — | — | ✓ |
cache.url | ✓ | — | — | — |
secrets.encryption_key, previous_encryption_keys | ✓ | ✓ | — | — |
registry.token.signing_key[_path], previous_signing_key | ✓ | ✓ | — | — |
registry.promoter_password, registry.events_secret | ✓ | — | — | — |
payments.stripe.secret_key, webhook_secret | ✓ | ✓ | — | — |
github.app_private_key[_path] | ✓ | ✓ | — | — |
github.webhook_secret, github.app_client_secret, auth.github_oauth.client_secret | ✓ | — | — | — |
github.stewardship.token | — | ✓ | — | — |
email.smtp_password, security.nvd_api_key | — | ✓ | — | — |
observability.sentry_dsn | optional | optional | optional | — |
storage.s3.* credentials | ✓ | ✓ | ✓ | — |
Non-secret settings (the GitHub App id, the OAuth client id, public URLs) may go to every
process; the validation rules that pair them with a secret apply only to the processes
that receive the secret. clctl commands that use only the database (migrate,
db check-role, audit verify, ledger verify, registry verify-preservation --enqueue) need nothing else; clctl config check still validates everything.
The chart generates no secrets; its README shows how to create them
(infrastructure/helm/commonlayer/README.md, "Install").
Installing on Kubernetes
The chart has passed its static tests; a cluster installation has run only in CI's kind
smoke test (infrastructure/helm/test-chart-kind.sh), not against a production cluster.
-
Provision PostgreSQL, a cache and S3-compatible buckets (artifacts, registry, backups), with versioning on the artifact and registry buckets. On AWS the OpenTofu modules create them (
infrastructure/terraform, validated, never applied). -
Create the two database roles (Database roles) and the Secrets listed above in the release namespace (the chart README has the commands).
-
Install the chart (
infrastructure/helm/commonlayer), setting at leastconfig.publicUrl,registry.publicHost,config.trustedProxies(see Client addresses), the ingress or Gateway API routes, and the integrations you use. The pre-install and pre-upgrade hook runsclctl migrateas the owner andclctl db check-roleas the runtime role; the processes refuse a schema they do not expect. -
Check readiness: every API, worker and scheduler pod reports ready on
/readyz(look at itsdetailsfordatabase_roleandrate_limiter);helm testrunscl-probeagainst the web server, the API and the registry. -
Create the first platform administrator:
kubectl exec -i deploy/<release>-api -- sh -c 'clctl user create-admin --email admin@example.org --username admin' <<< "$ADMIN_PASSWORD"clctl user create-adminreads the password fromCLCTL_ADMIN_PASSWORDor the first line of standard input and creates a verified administrator (or promotes the account with that email). Use an interactive shell rather than a command line that ends up in shell history. -
Configure monitoring: enable
metrics.serviceMonitor,metrics.prometheusRuleandmetrics.dashboardswith the Prometheus Operator and a Grafana sidecar, or load the files ofinfrastructure/monitoring/into your own stack. Restrict the ops ports to the monitoring namespace (networkPolicy.metricsFrom). -
Schedule backups (see backup and recovery) and run a restore test before taking contributions.
For evaluation, the chart can deploy the CommonLayer PostgreSQL and Valkey charts
alongside (evaluation.postgresql, evaluation.valkey); this is refused with
config.environment: production and is not highly available.
Running the binaries directly
Without Kubernetes, run the four binaries from one build with the same configuration:
clctl migrate # once per release
commonlayer-api # HTTP on server.http_addr
COMMONLAYER__SERVER__OPS_ADDR=0.0.0.0:9091 commonlayer-worker
COMMONLAYER__SERVER__OPS_ADDR=0.0.0.0:9092 commonlayer-scheduler
Each process needs its own ops port when they share a host. All handle SIGTERM
gracefully: the API stops accepting requests, the worker stops claiming and waits for
running jobs up to server.shutdown_grace_secs, the scheduler stops ticking.
Before taking real money
Taking contributions and paying people are separate steps, each gated:
- Configure a payment provider and its webhook endpoint
(
https://<host>/webhooks/payments/stripe), and verify a test payment end to end in the provider's test mode. The Stripe adapter has only been tested against fixtures and a mock server so far. - Watch at least one full monthly cycle with reconciliation clean before enabling payouts.
- Payouts stay off (
payments.payouts.mode = "dry_run") until the operating entity has configuredpayments.legal_entity, confirmed tax handling (payments.tax.handling_confirmed = true), set up the provider's payout capability, and two platform admins are in place. See ADR-0032.
Development stack
The development environment is documented in CONTRIBUTING.md:
make up starts PostgreSQL, Valkey, MinIO with its buckets (commonlayer-artifacts,
commonlayer-registry, commonlayer-backups, created by the one-off service
minio-init), the token-auth registry on :5000, a source registry on :5001 and
Mailpit, all from docker-compose.yml; make dev-backend runs
the binaries on the host.
The whole platform in containers, from the same docker-compose.yml:
| Command | Does |
|---|---|
make stack | builds the images (make images: backend, worker, web through scripts/dev/docker-build.sh, so builds work behind a TLS-intercepting proxy), then starts the app profile: stack-db-init (database commonlayer_stack, owner and runtime roles), migrate (clctl migrate as the owner, clctl db check-role as the runtime role), api, worker, scheduler, web |
make stack-up | the same without building |
make stack-seed | clctl seed in the backend image (profile tools), as the runtime role |
make stack-monitoring | adds the monitoring profile: OpenTelemetry Collector, Prometheus on :9095, Grafana on :3001 |
make stack-ps, make stack-logs | status and logs |
make stack-down | removes the platform and monitoring containers (infrastructure services and volumes stay) |
The containers run read-only as UID 10001 (tmpfs /tmp); images are not pulled
(pull_policy: never). Each backend container receives only the secrets of its process
(matrix). The web container has a fixed address on the internal
app network; no proxy is trusted by default (see Client addresses). Host ports
default to 8080/50051/9090/3000 and are overridable (CL_STACK_HTTP_PORT,
CL_STACK_GRPC_PORT, CL_STACK_OPS_PORT, CL_STACK_WEB_PORT) when a development backend
already uses them. The registry on :5000 uses the API on :8080 as its token realm;
when the stack runs on other ports next to a development backend, start the
registry-alt profile (registry on :5003, realm CL_ALT_API_PORT) and set
CL_STACK_REGISTRY_SERVICE=registry-alt CL_STACK_REGISTRY_HOST=localhost:5003.