Private betaZynth Auth is currently in private beta testing.New organizations are created by invitation only, and no plan can be purchased yet.Request early access

Self-hosting Zynth Auth

Self-hosting is a committed delivery tier: run Zynth Auth in your own region and infrastructure. This is an overview of the shape; detailed, versioned deployment steps ship with the distribution.

Roadmap status. Managed SaaS is the live delivery path today, and the supported, versioned distribution it depends on is now built: releases are cryptographically signed (cosign/keyless), packaged as verifiable offline bundles, served through a licence-gated, per-customer download (Downloading releases), and upgraded with customer-safe, self-service patching — connected or fully air-gapped (Versioning & upgrades). What remains a business decision, not an engineering one, is general commercial availability. The topology and security posture below are the exact stack we run in production and dogfood daily — that reference deployment is what the distribution packages.

Topology

A single-host deployment (Docker Compose) runs the following. The authoritative list is the deploy/docker-compose.yml inside your bundle — read it there rather than treating this summary as exhaustive; it also carries the runtime-security and archive sidecars that are not worth a paragraph here.

  • Reverse proxy (Caddy) — TLS termination and path routing.
  • Backend (API) — the FastAPI auth service, non-root, read-only rootfs.
  • Background workers — the security/command workers that process the event stream, detections, and the audit chain out of the request path.
  • Bootstrap + migrations — one-shot steps (database roles, then alembic upgrade head) that run before the API starts; the API waits for both to succeed.
  • PostgreSQL — the datastore.
  • Redis — cache/sessions.
  • Frontend — the admin/auth UI.
  • Documentation — this public developer documentation, served alongside the app at /docs. A second documentation container sits behind a basic-auth gate at /internal*, and the compose file requires its two credentials — DOCS_INTERNAL_USER and a bcrypt DOCS_INTERNAL_HASH (docker run --rm caddy:2.10-alpine caddy hash-password --plaintext '<pw>'). Leaving them unset does not disable the route; it stops the whole stack from starting, so set them in your env file before first boot.
  • Content and marketing containers — two services that start with the stack and are dormant on your origin. See Two containers you run but do not serve below; you need their images, and no route on your instance points at them.
  • Egress proxy — the outbound allowlist (see below).
  • Backup sidecar — encrypted off-box backups and point-in-time recovery (see Backups & recovery).
  • Observability — an OpenTelemetry collector, Prometheus and Grafana for service metrics (see Metrics & dashboards), plus Alertmanager for routing the built-in SLO alerts to email and Discord. All are optional; Alertmanager refuses to start without its receiver secrets rather than running silently unrouted.

Routing on one host — your instance is a single origin, everything under your own domain:

/                → frontend (sign-in + admin console)
/api/*           → backend
/.well-known/*   → backend (JWKS, OIDC discovery, security.txt)
/docs*           → documentation

What your origin does not serve. Zynth's own public website (pricing, trust centre, legal) is our marketing site, not part of the product, and no route in the block above reaches it — a self-hosted instance is the application only, so nothing about your install is publicly indexable by default.

Two containers you run but do not serve

Be aware of these before your first docker compose up, because the compose file is stricter than the routing table:

ServiceWhat it does on your installWhy it is still there
zynth-webAnswers its own /health and nothing else. What activates it — the analytics and content plane — is an overlay that is not part of a self-hosted install.The shipped docker-compose.registry.yml pins its image with :?, so the stack refuses to start without it.
marketingRuns Zynth's marketing site, which no route on your origin points at.Same: pinned in the shipped compose file, so the image must be present.

Two consequences worth knowing up front:

  • Both images must be in your bundle, or docker compose up aborts on the unset variable before anything starts — an interpolation error, not a service failure, so it does not look like a missing container. A message naming ZYNTH_IMAGE_ZYNTH_WEB (or ZYNTH_IMAGE_MARKETING) as required-and-missing is this, and it means the bundle is incomplete rather than that you configured something wrong. Report it; do not work around it by editing the compose file.
  • They cost you two idle containers, not two exposed surfaces. Neither joins your ingress routing, and both sit behind the same non-root, read-only, no-new-privileges posture as everything else below.

Security posture (defense-in-depth)

Zynth Auth is hardened to contain a compromise, not just prevent one — the runtime is boxed in several ways out of the box:

  • Least-privilege containers — every service runs non-root with a read-only filesystem, no-new-privileges, and all Linux capabilities dropped (only the few each image genuinely needs are added back).
  • Network isolation — Postgres and Redis run on an internal network with no route to the internet; the application containers have no direct internet either. All outbound traffic goes through a single egress allowlist proxy that permits only approved destinations and denies + logs everything else.
  • Database least privilege — the application connects with a DML-only database role (it cannot drop, truncate, alter schema, or reach another database); schema changes run only during migrations under a separate role.
  • Signed images — release images are cryptographically signed (cosign/keyless), and the deploy step verifies the signature before running them, so only the exact reviewed, built image runs.

Outbound connections (egress allowlist)

Because outbound traffic is default-deny, an instance only reaches an allowlisted set of hosts — e.g. the LLM provider that powers the AI documentation assistant, and the transactional email API. If you enable an integration that needs a new outbound destination, add it to the egress allowlist and redeploy the proxy; otherwise the call is blocked by design. (If AI docs or reset emails aren't working, this is the first thing to check — see Troubleshooting.)

Email delivery (password reset, verification)

Transactional email is configured with EMAIL_PROVIDER:

  • resend — a managed HTTPS email API (Resend). Set RESEND_API_KEY and EMAIL_FROM (an address on a domain you've verified with the provider). Outside development the instance refuses to boot with a half-configured provider (fail-closed). The API host (api.resend.com) is already on the reference egress allowlist, and sends carry idempotency keys so a retry can't double-deliver.
  • smtp — bring your own relay (SMTP_HOST/SMTP_PORT/SMTP_USER/SMTP_PASSWORD). Note the reference deployment's egress proxy forwards HTTPS only — raw SMTP (ports 587/465) does not traverse it, so the SMTP path assumes your own network posture allows the relay connection. With SMTP_HOST empty, sending is disabled (dev logs the message).

Every outbound email — success or failure — is recorded as an email.dispatched event in the audit trail, and a burst of failed sends raises an "Email delivery degraded" detection in the Command Center, so silent delivery loss is caught, not discovered by users.

Secrets live on the host, never in the image or git

  • An env file (database URL, app secrets) mounted read-only.
  • RS256 signing keys generated on the host into a secrets directory and mounted read-only into the API. The private key never leaves the host and is never committed.
  • A TLS certificate — self-signed for an IP-only deployment, or automatic Let's Encrypt once you point a domain at the host.

Fail-closed configuration. Outside development the instance refuses to start if required settings are missing — there is no insecure fallback. The complete required set is SECRET_KEY, JWT_SECRET_KEY, DATABASE_URL, REDIS_URL, PUBLIC_BASE_URL (a real https:// origin — present-but-localhost also fails), WEBAUTHN_RP_ID / WEBAUTHN_ORIGIN (passkeys — set the RP ID to your registrable domain, e.g. example.com, not a subdomain), and — with EMAIL_PROVIDER=resendRESEND_API_KEY / EMAIL_FROM. If the API or the one-shot migration won't boot, the log names the missing key. .env.example lists every setting with guidance.

MFA_ENCRYPTION_KEY is deliberately not on that list, and this page said it was until 2026-09-06. It encrypts stored TOTP secrets, and without it the instance boots normally and all other authentication works — only TOTP enrolment and verification are disabled, answering 503. That is the intended trade (locking every user out of a working install over one optional factor would be worse), but it means a missing key surfaces as "MFA is broken" long after deploy rather than as a refusal to start. Set it before you invite anyone who will use TOTP.

First boot

  1. Provide the env file and generate signing keys + a TLS cert on the host.
  2. Bring the stack up; migrations apply the schema automatically.
  3. Verify: GET /.well-known/jwks.json serves your public key, then run the Quickstart signup/login against your host.

Rotating the signing keys

Your instance signs tokens with an RS256 key that should be rotated periodically (the default policy is 90 days). Rotation is zero-downtime by design — no user is signed out and no in-flight token breaks — because JWKS carries up to three keys at once:

SlotRole
previousRetiring — verifies only, never signs. Published until the last token it signed has expired.
activeSigns new tokens.
nextStaged ahead of a cutover — published before it ever signs, so integrators' cached JWKS already trust it.

The sequence is stage → publish → promote → retire. Promotion is deliberately a human step: automation does everything reversible, and a person performs the one cutover that isn't. Every step is an env-file change plus a redeploy; the shipped scripts/generate-signing-keys.sh stages (--next <kid>) and retires (--retire <kid>) for you.

Two settings govern the lifecycle:

SettingMeaning
JWT_ROTATION_MAX_AGE_DAYSAge at which the instance starts warning (default 90; 0 disables grading for installs on their own calendar)
JWT_PROMOTED_ATThe date you promoted, written in the same edit that promotes. It starts the retirement clock

Your admin console's health view then does the remembering: it counts down ("previous key retirable in 12 days") and says RETIRABLE NOW once the retirement window has passed. With JWT_PROMOTED_AT unset it asks for the date rather than guessing — retiring a key early invalidates tokens that are still valid, while retiring late costs nothing but a little extra published key material.

Integrators should select keys by kid, never by position or count — during a rotation the first key in the JWKS document is the retiring one. See Verifying tokens.

Certificates and the browser warning

With no domain yet, the instance serves a self-signed certificate — browsers show a "not private" warning even though the connection is fully encrypted (TLS 1.3). Point a domain at the host to switch to a browser-trusted Let's Encrypt certificate.

Backups & recovery

The stack ships with an automated backup sidecar. It does nothing until you configure a destination — an unconfigured install has no off-box copy of your data, which is worth knowing explicitly rather than assuming.

What it does once configured:

PostgresA daily base backup plus continuous write-ahead-log shipping, giving point-in-time recovery — you can restore to a moment, not just to last night
RedisA daily snapshot, and a check that append-only persistence is actually healthy
EncryptionEverything is encrypted before it leaves the host, with a public key. The decryption key never touches the server, so a compromised host cannot read its own backup history
RetentionEnforced by your object store's lifecycle policy, so it keeps working even if the sidecar stops
DestinationAny S3-compatible object storage

Set BACKUP_S3_*, BACKUP_AGE_RECIPIENT and ZYNTH_BACKUP_PASSWORD in your env file. Generate the encryption keypair with age off the server and keep the private key somewhere safe — losing it makes every backup permanently unreadable.

Test your restores. A backup you have never restored is a hypothesis. The distribution ships scripts/restore-drill.sh — one command, needing only Docker on the machine you run it from. It fetches and decrypts your newest base backup and WAL, replays them into a scratch container (never your live volumes), queries the restored database, and times the whole thing. Run it quarterly and after any topology change; if it takes longer than your recovery target, that gap is real information. It only ever reads your backup store.

It proves five things separately, because "the restore worked" is too coarse: your decryption key works · the WAL archive is readable · Postgres actually replays it (point-in-time recovery, not just unpacking a snapshot) · the restored data answers queries · your logical dump is loadable.

Running more than one API instance

The API is stateless (sessions live in Redis), so you can run several instances behind the proxy: set BACKEND_REPLICAS in your env file.

Two things to know:

  • Only the API scales. The background workers are deliberately single-instance — one of them is the sole writer of the tamper-evident audit chain, and running two would be duplicated work at best. The compose file already enforces this; don't add replicas to them.
  • Replicas alone are not high availability. They remove the process as a single point of failure, not the host, the database, or the proxy. On one machine, a crashed or redeploying API container stops taking traffic down — which is a real improvement, and is also the honest limit of what it buys you.

Metrics & dashboards

The API is instrumented with OpenTelemetry and pushes request, database and cache metrics over OTLP. Nothing is exposed as a scrapeable endpoint on the API itself, so the service gains no extra listening surface.

  • Leave OTEL_EXPORTER_OTLP_ENDPOINT unset and instrumentation never initialises — no overhead, no dependency. This is a fully supported way to run.
  • Point it at the bundled collector (the default in the compose file) to get Prometheus + Grafana with a pre-built service dashboard, provisioned from files rather than clicked together.
  • Point it at your own OTLP backend — a hosted APM or your existing collector — and skip the bundled ones entirely. The instrumentation is vendor-neutral.

Set GRAFANA_ADMIN_PASSWORD if you run the bundled Grafana. It is not published on your domain and has no anonymous access; reach it over an SSH tunnel:

Grafana runs on the internal network and publishes no host port (a container on an internal-only network cannot), so forward to its container address rather than to the host's own loopback:

GRAFANA_IP=$(ssh your-host 'docker inspect $(docker compose ps -q grafana) --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}"') \
  && ssh -N -L 3001:"$GRAFANA_IP":3000 your-host -o ExitOnForwardFailure=yes

Then open http://localhost:3001. Keep ExitOnForwardFailure=yes: without it SSH quietly opens a session with no forward when the local port is already taken, and your browser shows whatever else is running there. Keep the local port at 3001 unless you also change GF_SERVER_ROOT_URL — Grafana builds its redirects from that value.

Metrics never contain identity. Labels carry route templates and status codes only — no user ids, emails, tenant ids or IP addresses, enforced both at the SDK and again in the collector. Identity-bearing events live in the audit log and the Command Center, which are tenant-scoped and tamper-evident; the metrics store is deliberately not a second copy of them.

Capabilities that ship dormant

Some surfaces are off in a fresh install and stay off until an operator turns them on — attack surface and, in one case, authority are opt-in rather than inherited from a deploy. The ones to know about:

A dormant surface answers 404, byte-identically to a route that does not exist — the honest posture (ADR-0084): a feature you have not enabled must not advertise itself to someone probing your install. The consequence for you is that you cannot discover these by exploring the API. This table is the list.

CapabilityGateOff means
Enterprise SSO (OIDC + SAML) and SCIM provisioningSSO_FEDERATION_ENABLED/api/v1/auth/sso/*, /api/v1/tenants/sso/*, /api/v1/tenants/scim/* and /scim/v2/* all return 404. Your IdP integration and directory sync simply are not there.
Human approval for agent actionsAGENT_APPROVALS_ENABLED/api/v1/approvals/* returns 404, and every agent write tool over MCP is refused outright — an agent can read, never act.
Access governance — access requests + JIT, separation of duties, certification campaignsAUTHZ_GOVERNANCE_ENABLED/api/v1/authz/{requests,sod-rules,campaigns} return 404; roles, policies and the check API stay live. Requires AGENT_APPROVALS_ENABLED as well — the config validator refuses to boot with governance on and approvals off, deliberately: governance decisions ride the approval ledger.
Agent-driven onboarding & IAM-as-codeONBOARD_BOOTSTRAP_ENABLEDThe whole /api/v1/onboard/* surface returns 404 — no agent can open an organization on a human's behalf. The manifest endpoints stay live for agents that already exist.
MCP tool gateway (routing your agents' calls to other MCP servers)MCP_GATEWAY_ENABLED/api/v1/mcp/call and /api/v1/mcp/servers return 404. Note this is not Zynth's own MCP server at /api/v1/mcp, which is always live.
Enterprise workload federationAGENT_FEDERATION_ENABLEDThe attestation registry and the federated exchange return 404. Classic agent credentials keep working.
Public signup — new organizations (Joining the private beta)PUBLIC_SIGNUP_ENABLEDNot a 404: while off (the default) every anonymous door that creates an organization refuses — the agent bootstrap requires an owner-issued beta invite code (403, one message for every cause) and POST /api/v1/auth/signup answers 403 naming the early-access page. Login, credential flows, invitation acceptance and per-tenant customer signup are untouched.
Self-service password resetPASSWORD_RESET_ENABLEDThe one flag in this table that ships ON, and deliberately so: closing password recovery locks real users out of their own accounts, so a self-host install keeps it working unless you decide otherwise. Setting it false makes POST /api/v1/auth/forgot-password answer 403 naming PASSWORD_RESET_CLOSED_CONTACT (empty = "your administrator") — identically for a known and an unknown address, so no enumeration oracle. POST /api/v1/auth/reset-password is not gated, so a link already sent still works; magic-link sign-in and change-password are untouched.
Self-serve purchases (Plans and entitlements)PURCHASES_ENABLEDNot a 404: POST /api/v1/billing/subscribe answers 403 naming the reason ("Purchases are not open during the private beta") while the rest of /api/v1/billing keeps working, and the public pricing projection reports purchases_open: false. Off by default until prices exist.
Payment securityPAYMENTS_STEPUP_ENABLEDThe whole /api/v1/payments/* surface returns 404 on every method — step-up, SCA and risk context alike, indistinguishable from a build without the feature (ADR-0084).
Autonomous responseAUTONOMY_EXECUTION_ENABLEDThe response engine records what it would have done and executes nothing — no actions, no proposals — whatever any organization's levels say.

Managed-cloud customers do not set these: ask support what is enabled for your organization. Some capabilities are additionally plan-gated — the flag makes a surface exist, the entitlement decides who may use it (Plans and entitlements).

The autonomy gate is the one that matters most on a fresh install: it is a capability that can end users' sessions, so switching it on is a deliberate operator act and never a side effect of deploying a new version. Before you enable it, set COMMAND_ALERT_EMAIL and/or COMMAND_ALERT_WEBHOOK_URL — unattended actions notify on those channels, and an install with neither configured would act without telling anyone. The full variable list and the operating model are in Operating autonomous response.

Keeping it current

How you upgrade depends on the version bump, and the version number tells you how much care it needs: patches apply themselves, unattended and reversibly; minor and major upgrades run a short backed-up runbook. The full contract — including the guarantee that a patch never touches your database — is in Versioning & upgrades.