# Rekey — SELF-HOST production stack (Traefik-routed, e.g. Dokploy). # # Deploys: postgres + redis (PRIVATE network) + api + panel + portal, each on a # hostname YOU supply. This is the file DEPLOY.md documents and the one that # ships to the public mirror. # # NOT the file Rekey Cloud runs. Our own hosted deployment is one independent # compose per unit against managed Postgres — see the header of # the per-unit composes Rekey runs. Those exist so each unit deploys and rolls back on its # own; this one exists so a self-hoster gets the whole product from a single # `up`. Keeping them separate is what stops # our hostnames, our analytics ids and our mail sender leaking into a self-host # deployment, which is exactly what happened while this file carried them as # literals: `PUBLIC_WEBHOOK_BASE_URL: https://api.rekey.dev` meant a self-hoster # configured their own Stripe account to POST payment webhooks at OUR server. # # EVERY deployment-specific value below is `${VAR}` with no Rekey default. Three # of them have no default at all and fail the `docker compose` command when # unset — API_HOST, PANEL_HOST, PORTAL_HOST — because there is no value we could # pick for them that is not either wrong or ours. # # Notes: # - Set this as the Compose file of a Dokploy "Compose" service (or run it # with `docker compose -f docker-compose.prod.yml up -d` behind your own # Traefik). # - All values come from the deployment environment (.env.production / # the Dokploy service Environment). See `.env.production.example`. # - Service names (api/panel/portal) MUST match the Dokploy domain # attachments — don't rename them. # - NETWORK ISOLATION (important): Postgres + Redis live on a PRIVATE # `rekey-internal` network and are NOT on the shared `dokploy-network`. # On the shared network a service name is a DNS alias, so a `postgres`/ # `redis` there collides with other Dokploy apps' DBs (it once shadowed a # sibling app's `postgres`, failing its auth). Keeping the DB/cache private # prevents that; only the web services join `dokploy-network` for Traefik. # - DNS: point A records for the domains at the host BEFORE deploying # (Let's Encrypt needs them resolvable). # - Migrations run automatically: the api container executes # `prisma migrate deploy` on start (idempotent) before booting. # - The api `environment:` block below is an ALLOWLIST and is deliberately # COMPLETE: every variable `apps/api/src/config/env.ts` reads is named here, # even the ones that are empty by default. A variable missing from the block # never reaches the container, so setting it in Dokploy silently does # nothing — five separate incidents of exactly that shape are recorded in # this repo's history. `.github/scripts/check-compose-env.mjs` now fails CI # when a new key is added to env.ts without landing here. services: postgres: image: postgres:16-alpine restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } environment: POSTGRES_USER: rekey POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: rekey volumes: - rekey_postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U rekey -d rekey"] interval: 5s timeout: 5s retries: 10 networks: [rekey-internal] redis: image: valkey/valkey:8.1-alpine restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } # AOF persistence so BullMQ webhook-delivery jobs (delayed retries) survive a # restart. The DB retry poller backstops any gap; durable queue state # avoids needless re-deliveries on a bounce. # # This mounts a NEW volume (rekey_valkey), not the old rekey_redis one: a # Redis 7.4 AOF/RDB file (anything written since the redis:7-alpine tag # started resolving to 7.4) makes Valkey refuse to start, which would turn # this image swap into a boot-time outage. Starting empty is safe — see # "Why Valkey, not Redis" in DEPLOY.md for exactly what resets and why. # `docker volume rm rekey_redis` once you've confirmed you don't need it. command: [ "valkey-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env (openssl rand -hex 24)}", ] environment: # valkey-cli reads this, so the healthcheck doesn't put the password in an # argv element visible via `docker inspect`. # # The --requirepass above uses ${VAR:?err} because Valkey treats an empty # password as no password and boots open, with nothing to notice. # # NOTE: the password lands in REDIS_URL unencoded, so keep it URL-safe. # `openssl rand -hex 24` is; a password containing @ : / or # would not be. REDISCLI_AUTH: ${REDIS_PASSWORD} volumes: - rekey_valkey:/data healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 5s retries: 10 networks: [rekey-internal] api: build: context: . target: api-runtime image: rekey-api:prod restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } healthcheck: # Liveness, deliberately NOT /health/ready: restarting the API cannot fix a # Postgres or Redis outage, and a restart loop during one slows recovery. # Traefik/LB health gating should use /health or /health/ready instead. test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3030/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] interval: 15s timeout: 5s retries: 5 start_period: 40s environment: NODE_ENV: production # PORT and HOST are deliberately NOT settable here: the healthcheck above # and the Traefik port label below both hardcode 3030, so a changed port # would route traffic nowhere while every probe still passed. DATABASE_URL: postgresql://rekey:${POSTGRES_PASSWORD}@postgres:5432/rekey?schema=public REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 # How long one API process may reuse its snapshot of an Application's # organization-role catalog. Writes invalidate locally and publish over # Redis, so this is the backstop for a Redis outage, not the normal # propagation path. 0 disables the cache. See docs/organization-roles.md. ORG_ROLE_CACHE_TTL_MS: ${ORG_ROLE_CACHE_TTL_MS:-} OPERATOR_AUTH_CACHE_TTL_MS: ${OPERATOR_AUTH_CACHE_TTL_MS:-} # ── Token lifetimes ────────────────────────────────────────────────── # Refresh tokens rotate on every use and slide, so these windows only # have to cover the longest gap between two uses, not how long someone # stays signed in. Empty = the values they were hard-coded to before # they became configurable: 15 minute access, 30 day refresh. END_USER_ACCESS_TOKEN_TTL_SECONDS: ${END_USER_ACCESS_TOKEN_TTL_SECONDS:-} END_USER_REFRESH_TOKEN_TTL_DAYS: ${END_USER_REFRESH_TOKEN_TTL_DAYS:-} OPERATOR_ACCESS_TOKEN_TTL_SECONDS: ${OPERATOR_ACCESS_TOKEN_TTL_SECONDS:-} OPERATOR_REFRESH_TOKEN_TTL_DAYS: ${OPERATOR_REFRESH_TOKEN_TTL_DAYS:-} # Seconds a just-rotated refresh token may be replayed without revoking # every session (answers REFRESH_TOKEN_RACED). Empty = 15; 0 = off. REFRESH_TOKEN_REUSE_WINDOW_SECONDS: ${REFRESH_TOKEN_REUSE_WINDOW_SECONDS:-} # ── Secrets ────────────────────────────────────────────────────────── JWT_SECRET: ${JWT_SECRET} ENCRYPTION_KEY: ${ENCRYPTION_KEY} SUPER_ADMIN_KEY: ${SUPER_ADMIN_KEY} # Optional RS256 signing key for end-user access tokens. Unset = a keypair # is generated on first use and stored (private half encrypted). JWT_RS256_PRIVATE_KEY: ${JWT_RS256_PRIVATE_KEY:-} # Network gate on the super-admin surface, checked BEFORE the key # comparison (middleware/admin-auth.ts). Comma-separated IPs/CIDRs; unset # means no network restriction, which is how it shipped. ADMIN_IP_ALLOWLIST: ${ADMIN_IP_ALLOWLIST:-} # ── Public origins ─────────────────────────────────────────────────── # Derived from the three hostnames so there is one place to change a # domain. Override any of them individually if your public origin differs # from what Traefik routes (a path prefix, a CDN, a different scheme). API_URL: ${API_URL:-https://${API_HOST}} # The origin Rekey auto-registers with Stripe/PayPal as the destination # for YOUR payment webhooks. It must be internet-reachable and it must be # yours — see the header. PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-https://${API_HOST}} # Hosted customer portal origin — auto-allowed for publishable-key calls # from portal-enabled apps (CORS + per-app origin check). Must be the # same origin as the portal's PORTAL_BASE_URL. PUBLIC_PORTAL_URL: ${PUBLIC_PORTAL_URL:-https://${PORTAL_HOST}} # Kill switch for the Rekey-hosted checkout page: `false` sends every # checkout to the provider's page (or refuses it, per Application). CHECKOUT_EMBEDDED_ENABLED: ${CHECKOUT_EMBEDDED_ENABLED:-} # Checkout creation ceilings per client IP and per Application, per hour. CHECKOUT_LIMIT_PER_IP_HOUR: ${CHECKOUT_LIMIT_PER_IP_HOUR:-} CHECKOUT_LIMIT_PER_APP_HOUR: ${CHECKOUT_LIMIT_PER_APP_HOUR:-} # Operator-facing links: the MCP OAuth consent redirect and emailed # workspace invites are built from this. PANEL_URL: ${PANEL_URL:-https://${PANEL_HOST}} CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-https://${PANEL_HOST},https://${PORTAL_HOST}} # Base origin for the operator OAuth redirect URI # `/login/oauth//callback`. Falls back to the first CORS # entry (preferring a `panel.` host) when unset. PANEL_OAUTH_REDIRECT_BASE: ${PANEL_OAUTH_REDIRECT_BASE:-} # Last-resort base URL for links in transactional emails, after the SDK # call, the per-Application setting, and the app's first redirect URL. # Empty = unset, and unset means emails omit the button rather than link # somewhere broken. DEFAULT_APP_URL: ${DEFAULT_APP_URL:-} # Whose X-Forwarded-For to believe: the panel and the portal, by their # fixed addresses on the private rekey-edge network below, and nobody # else. Both call the API server-side and forward the visitor's address; # without this every operator and every portal visitor shares one bucket, # and one person spraying the login page locks every operator out. # # Never a hop count here: the API also sits on dokploy-network, which # other Dokploy apps share, and a hop count believes the header from any # of them (they could then claim an ADMIN_IP_ALLOWLIST address). Traefik # is not trusted by address (it has no fixed one); it proves itself with # API_PROXY_SECRET below. docs/rate-limits.md. TRUSTED_PROXIES: ${TRUSTED_PROXIES:-${REKEY_PANEL_EDGE_IP:-10.203.53.10},${REKEY_PORTAL_EDGE_IP:-10.203.53.11}} # --- Traefik proxy secret (rate limiting; see docs/rate-limits.md) --- # Traefik sends this as X-Rekey-Proxy-Secret on every request (label # below), overwriting any client copy; only then does the API believe # X-Forwarded-For, taking the API_PROXY_HOPS-th entry from the right # (1 = Traefik alone; 2 = a CDN in front, which Traefik must be told to # trust). REQUIRED, like PANEL_PROXY_SECRET below: without it public # traffic through Traefik gets no per-IP limits at all, only per-key, # per-account and per-Application ones, and the only sign of it is one # line in the boot log. A self-hoster who reads neither is running with # the sign-in and credential-failure limits off. Generate with: # openssl rand -hex 32 API_PROXY_SECRET: ${API_PROXY_SECRET:?set API_PROXY_SECRET in .env, e.g. openssl rand -hex 32} API_PROXY_HOPS: ${API_PROXY_HOPS:-1} # Record end users' country from Cloudflare's CF-IPCountry. Only `true` # when the proxy above receives nothing but Cloudflare traffic; see # docs/analytics.md. Unset: no country is recorded. TRUST_CF_IPCOUNTRY: ${TRUST_CF_IPCOUNTRY:-} # The hourly analytics rollup; on unless `false`. See docs/analytics.md. ANALYTICS_ROLLUP_ENABLED: ${ANALYTICS_ROLLUP_ENABLED:-} # The panel and portal reach the API over rekey-edge and are trusted by # address for rate limiting. This secret is what additionally lets the # API believe the visitor's User-Agent and country the portal forwards, # so a portal sign-in records the visitor's browser and platform rather # than the portal's own `node` (docs/analytics.md). Same value on the # API, panel and portal; all three read it from this .env. INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:?set INTERNAL_CALLER_SECRET in .env, e.g. openssl rand -hex 32} # ── Transactional email (Resend) ───────────────────────────────────── # Unset = Rekey sends no mail and returns raw tokens to the caller # instead. The from-address must be on a domain YOU verified in Resend. RESEND_DEFAULT_API_KEY: ${RESEND_DEFAULT_API_KEY:-} RESEND_DEFAULT_FROM: ${RESEND_DEFAULT_FROM:-} RESEND_DEFAULT_FROM_NAME: ${RESEND_DEFAULT_FROM_NAME:-} EMAIL_SEND_DAILY_CAP: ${EMAIL_SEND_DAILY_CAP:-} EMAIL_SEND_RECIPIENT_HOURLY_CAP: ${EMAIL_SEND_RECIPIENT_HOURLY_CAP:-} AUTH_EMAIL_RECIPIENT_HOURLY_CAP: ${AUTH_EMAIL_RECIPIENT_HOURLY_CAP:-} AUTH_EMAIL_RECIPIENT_DAILY_CAP: ${AUTH_EMAIL_RECIPIENT_DAILY_CAP:-} EMAIL_UNSUBSCRIBE_SECRET: ${EMAIL_UNSUBSCRIBE_SECRET:-} EMAIL_UNSUBSCRIBE_SECRET_ID: ${EMAIL_UNSUBSCRIBE_SECRET_ID:-} EMAIL_UNSUBSCRIBE_PREVIOUS_SECRETS: ${EMAIL_UNSUBSCRIBE_PREVIOUS_SECRETS:-} # ── Registration + tenancy policy ──────────────────────────────────── # Who may create an OPERATOR account: open | invite | closed. Empty = # open, which is self-serve sign-up for anyone who can reach the panel. # A private deployment wants `invite` or `closed`; there was previously no # way to set either, because this variable was missing from this block. OPERATOR_SIGNUP_MODE: ${OPERATOR_SIGNUP_MODE:-} # Whether a signed-in operator may create ADDITIONAL workspaces: # open (default) | disabled. Pairs with DEFAULT_TENANT_LIMITS — a ceiling # per workspace means little if anyone can mint a fresh one. WORKSPACE_CREATION: ${WORKSPACE_CREATION:-} # Whether OWNER/ADMIN may grant a subscription from the panel with no # payment provider behind it: enabled (default) | disabled. Granting only; # cancelling is never gated. Set `disabled` if this deployment SELLS to the # workspaces it hosts, where a granted subscription can write the granter's # own allowance; granting then stays on the super-admin key. TENANT_SUBSCRIPTION_GRANTS: ${TENANT_SUBSCRIPTION_GRANTS:-} # Starting ceilings for every workspace this deployment creates (JSON, # TenantLimitsSchema — e.g. {"maxProductionApps":1}). Empty = unlimited. DEFAULT_TENANT_LIMITS: ${DEFAULT_TENANT_LIMITS:-} # ── Operator panel auth (WebAuthn + social login) ──────────────────── # One relying party across all tenants. `rpId` is the panel hostname (no # scheme, no port); unset means operator passkeys refuse with # WEBAUTHN_NOT_CONFIGURED rather than binding to a guessable id. PANEL_WEBAUTHN_RP_ID: ${PANEL_WEBAUTHN_RP_ID:-} PANEL_WEBAUTHN_RP_ORIGINS: ${PANEL_WEBAUTHN_RP_ORIGINS:-} PANEL_WEBAUTHN_RP_NAME: ${PANEL_WEBAUTHN_RP_NAME:-} # A provider activates only when BOTH its id and secret are set. PANEL_OAUTH_GOOGLE_CLIENT_ID: ${PANEL_OAUTH_GOOGLE_CLIENT_ID:-} PANEL_OAUTH_GOOGLE_CLIENT_SECRET: ${PANEL_OAUTH_GOOGLE_CLIENT_SECRET:-} PANEL_OAUTH_GITHUB_CLIENT_ID: ${PANEL_OAUTH_GITHUB_CLIENT_ID:-} PANEL_OAUTH_GITHUB_CLIENT_SECRET: ${PANEL_OAUTH_GITHUB_CLIENT_SECRET:-} # "Sign in with " — operator sign-in against one of this # deployment's own Applications, via the generic OIDC provider. All three # are needed together; the issuer is `https:///api/v1/mcp/`. PANEL_OAUTH_REKEY_CLIENT_ID: ${PANEL_OAUTH_REKEY_CLIENT_ID:-} PANEL_OAUTH_REKEY_CLIENT_SECRET: ${PANEL_OAUTH_REKEY_CLIENT_SECRET:-} PANEL_OAUTH_REKEY_ISSUER: ${PANEL_OAUTH_REKEY_ISSUER:-} # ── Feature switches + limits ──────────────────────────────────────── # Operator/tenant MCP HTTP surface. Empty = enabled; only the literal # "false" turns it off (it exposes the mint_api_key write tool). OPERATOR_MCP_ENABLED: ${OPERATOR_MCP_ENABLED:-} # RFC 7591 dynamic client registration on the operator MCP authorization # server: empty/`open` = anyone may register a client (how Claude Desktop # / Code / Cursor connect); `disabled` closes it. Worth closing once your # clients are connected — a registration allowlists a redirect_uri of the # registrant's choosing, which is what a consent-phishing link needs. OPERATOR_MCP_DYNAMIC_REGISTRATION: ${OPERATOR_MCP_DYNAMIC_REGISTRATION:-} # Operator sign-in by OIDC ID Token assertion # (POST /api/v1/tenant/auth/oidc/assert). BOTH must be set or the # endpoint 404s — a deployment that has not opted in has no assertion # surface at all. This is how Rekey Cloud signs a buyer into the panel # with the account they already have on the marketing site. OPERATOR_OIDC_ISSUER: ${OPERATOR_OIDC_ISSUER:-} OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID:-} # Kill-switch for the HIBP Pwned Passwords lookup — "true" for offline or # restricted-egress deployments. HIBP_BREACH_CHECK_DISABLED: ${HIBP_BREACH_CHECK_DISABLED:-} # "true" lets outbound webhooks target private/internal addresses. Leave # empty on an internet-facing deployment: it is the SSRF guard. WEBHOOK_ALLOW_PRIVATE_TARGETS: ${WEBHOOK_ALLOW_PRIVATE_TARGETS:-} # Outbound webhook request timeout in ms. Empty = 10000; allowed range # 1000-30000. Raise it for receivers that answer slowly from a cold start. WEBHOOK_TIMEOUT_MS: ${WEBHOOK_TIMEOUT_MS:-} # Outbound webhook sends one Application may have in flight at once, # across replicas and endpoints. Empty = 8. Keeps one tenant with many # slow endpoints from holding every delivery slot. WEBHOOK_APP_MAX_IN_FLIGHT: ${WEBHOOK_APP_MAX_IN_FLIGHT:-} # How long a processed inbound billing webhook is remembered, which is # what makes a provider's redelivery idempotent. Empty = kept forever; # set a number of days to turn pruning on. WEBHOOK_EVENT_RETENTION_DAYS: ${WEBHOOK_EVENT_RETENTION_DAYS:-} # Days the append-only log tables keep rows in Postgres (security_events, # email_logs, finished webhook_deliveries). Empty = kept forever; set a # number of days to turn pruning on. usage_records is a billing ledger # and is never pruned by this. LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-} # Optional S3-compatible archive (Cloudflare R2 or AWS S3) rows are written # to before they are pruned. Endpoint, bucket and both keys together, or # none: a partial set stops the API from booting. Region empty = auto. LOG_ARCHIVE_S3_ENDPOINT: ${LOG_ARCHIVE_S3_ENDPOINT:-} LOG_ARCHIVE_S3_BUCKET: ${LOG_ARCHIVE_S3_BUCKET:-} LOG_ARCHIVE_S3_REGION: ${LOG_ARCHIVE_S3_REGION:-} LOG_ARCHIVE_S3_ACCESS_KEY_ID: ${LOG_ARCHIVE_S3_ACCESS_KEY_ID:-} LOG_ARCHIVE_S3_SECRET_ACCESS_KEY: ${LOG_ARCHIVE_S3_SECRET_ACCESS_KEY:-} LOG_ARCHIVE_S3_PREFIX: ${LOG_ARCHIVE_S3_PREFIX:-} # Rate limits, per RATE_LIMIT_WINDOW_MS (60s). Empty = the defaults: # 100 per client IP unauthenticated, 600 per operator or signed-in end # user, 3000 per IP across those, 30000 per secret API key (and for # POST /api/v1/usage/record), 3000 per Application across its sign-in, # sign-up, MFA, magic-link, reset and verify routes (100 of that per # client IP), 100 rejected credentials per IP, 60 operator refreshes per # IP. Sizing: docs/rate-limits.md. RATE_LIMIT_MAX: ${RATE_LIMIT_MAX:-} RATE_LIMIT_WINDOW_MS: ${RATE_LIMIT_WINDOW_MS:-} RATE_LIMIT_AUTHENTICATED_MAX: ${RATE_LIMIT_AUTHENTICATED_MAX:-} RATE_LIMIT_API_KEY_MAX: ${RATE_LIMIT_API_KEY_MAX:-} RATE_LIMIT_AUTH_CEILING_MAX: ${RATE_LIMIT_AUTH_CEILING_MAX:-} RATE_LIMIT_AUTH_UNATTRIBUTED_FAILURE_MAX: ${RATE_LIMIT_AUTH_UNATTRIBUTED_FAILURE_MAX:-} RATE_LIMIT_AUTHENTICATED_IP_MAX: ${RATE_LIMIT_AUTHENTICATED_IP_MAX:-} RATE_LIMIT_AUTH_FAILURE_MAX: ${RATE_LIMIT_AUTH_FAILURE_MAX:-} RATE_LIMIT_REFRESH_MAX: ${RATE_LIMIT_REFRESH_MAX:-} RATE_LIMIT_USAGE_MAX: ${RATE_LIMIT_USAGE_MAX:-} # Prisma connection-pool sizing. Empty = 20 connections / 10s timeout; # read the docblock in apps/api/src/lib/prisma.ts before lowering it, as # the webhook worker runs at concurrency 10 against the same client. DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-} DATABASE_POOL_TIMEOUT_SECONDS: ${DATABASE_POOL_TIMEOUT_SECONDS:-} labels: - traefik.enable=true - traefik.http.routers.rekey-api.rule=Host(`${API_HOST:?set API_HOST in .env, e.g. api.example.com}`) - traefik.http.routers.rekey-api.entrypoints=websecure - traefik.http.routers.rekey-api.tls.certresolver=letsencrypt - traefik.http.services.rekey-api.loadbalancer.server.port=3030 # Proxy secret (see API_PROXY_SECRET above). - traefik.http.middlewares.rekey-api-proxy-secret.headers.customrequestheaders.X-Rekey-Proxy-Secret=${API_PROXY_SECRET} - traefik.http.routers.rekey-api.middlewares=rekey-api-proxy-secret # dokploy-network: reachable by Traefik. rekey-internal: reach postgres/redis. # rekey-edge: the panel and portal reach it here, under a name that exists # on no other network, so their calls always arrive from the fixed # addresses TRUSTED_PROXIES names. networks: rekey-internal: {} dokploy-network: {} rekey-edge: aliases: [rekey-api-edge] panel: build: context: . target: panel-runtime args: # Compiled into the client bundle — the runtime `environment:` below # cannot change these after the build. NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-https://${API_HOST}} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-https://${PANEL_HOST}} # Your analytics property, not ours. No default: a default here would # report a self-hoster's operators to Rekey's GA4. NEXT_PUBLIC_GA_MEASUREMENT_ID: ${NEXT_PUBLIC_GA_MEASUREMENT_ID:-} image: rekey-panel:prod restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } depends_on: - api environment: NODE_ENV: production # Server-side panel → API calls stay on the private rekey-edge network, # from the fixed address the API trusts (see TRUSTED_PROXIES). REKEY_URL: http://rekey-api-edge:3030 PANEL_URL: ${PANEL_URL:-https://${PANEL_HOST}} # Traefik is the one proxy in front of the panel. Count every proxy that # appends to X-Forwarded-For (a CDN in front of Traefik makes it 2). PANEL_TRUSTED_PROXIES: ${PANEL_TRUSTED_PROXIES:-1} # Traefik presents this on every request (label below), and the panel # believes X-Forwarded-For only when it does, so nothing that reaches the # panel around Traefik can pick the IP the API rate-limits sign-in on. # Required: without it every operator would share Traefik's address. # Generate with: openssl rand -hex 32 PANEL_PROXY_SECRET: ${PANEL_PROXY_SECRET:?set PANEL_PROXY_SECRET in .env, e.g. openssl rand -hex 32} # Optional here (the panel reaches the API on the private network). When # set, sent to the API as X-Rekey-Caller-Secret; must match the API's value. INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:-} # Leave empty. Session cookies get `Secure` from the request — Traefik # terminates TLS and sets X-Forwarded-Proto: https, which is what the # panel reads. Only set this (to "false") if you front this stack with # something that serves plain HTTP and sets no forwarded proto, and you # accept session cookies travelling in the clear. REKEY_COOKIE_SECURE: ${REKEY_COOKIE_SECURE:-} # Where THIS deployment points an operator who authenticated fine but has # not been granted access here, and what the link says. Both optional: # unset, the panel states the fact and offers no destination, which is # right when you hand out invite keys by your own means. This block is an # allowlist — a variable set in .env but missing here never reaches the # container, which is silent and has bitten this file before. PANEL_SIGNUP_HELP_URL: ${PANEL_SIGNUP_HELP_URL:-} PANEL_INVITE_HELP_URL: ${PANEL_INVITE_HELP_URL:-} PANEL_INVITE_HELP_TEXT: ${PANEL_INVITE_HELP_TEXT:-} labels: - traefik.enable=true - traefik.http.routers.rekey-panel.rule=Host(`${PANEL_HOST:?set PANEL_HOST in .env, e.g. panel.example.com}`) - traefik.http.routers.rekey-panel.entrypoints=websecure - traefik.http.routers.rekey-panel.tls.certresolver=letsencrypt - traefik.http.services.rekey-panel.loadbalancer.server.port=3031 # Sets (and so overwrites any client copy of) the header the panel checks # against PANEL_PROXY_SECRET before believing X-Forwarded-For. - traefik.http.middlewares.rekey-panel-proxy-secret.headers.customrequestheaders.X-Rekey-Proxy-Secret=${PANEL_PROXY_SECRET} - traefik.http.routers.rekey-panel.middlewares=rekey-panel-proxy-secret networks: dokploy-network: {} rekey-edge: ipv4_address: ${REKEY_PANEL_EDGE_IP:-10.203.53.10} # Hosted multi-app customer portal (Portal V2) at /. # ONE deployment serves every opted-in Application — no per-app deploy, no # secret key. Operators enable it per Application in Panel → Application → # Billing → Portal. The portal identifies each app by its publishable key # (fetched via /api/v1/portal/config/:slug) and authorizes users with their # own token. See docs/portal.md. # # Not running a customer portal? Delete this service AND the two PORTAL_HOST # references above: the api's `PUBLIC_PORTAL_URL` and its `CORS_ALLOWED_ORIGINS` # entry. Leaving them with # PORTAL_HOST unset would hand the API the string `https://`, which is not a # URL and fails the boot. portal: build: context: . target: portal-runtime image: rekey-portal:prod restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } depends_on: - api environment: NODE_ENV: production # Server-side portal → API calls stay on the private rekey-edge network. REKEY_URL: http://rekey-api-edge:3030 # --- Visitor address forwarding (rate limiting; docs/rate-limits.md) --- # The portal forwards the visitor's address to the API so per-IP limits # are per visitor, not per portal, but only for a request that carries # PORTAL_PROXY_SECRET as X-Rekey-Proxy-Secret (Traefik adds it, label # below), so a container that reaches the portal around Traefik cannot # choose the address. Hops: proxies in front (1 = Traefik alone). # REQUIRED: unset, the portal's own address is what every visitor's # requests are counted against, so one visitor can exhaust the bucket # for all of them. Generate with: openssl rand -hex 32 PORTAL_TRUSTED_PROXY_HOPS: ${PORTAL_TRUSTED_PROXY_HOPS:-1} PORTAL_PROXY_SECRET: ${PORTAL_PROXY_SECRET:?set PORTAL_PROXY_SECRET in .env, e.g. openssl rand -hex 32} # Sent to the API as X-Rekey-Caller-Secret, which is what makes the API # believe the visitor's User-Agent and country the portal forwards on a # sign-in. Must equal the api service's value (the same .env key). INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:?set INTERNAL_CALLER_SECRET in .env, e.g. openssl rand -hex 32} # Public URL of this portal: checkout return links and password-reset # links. Must be the same origin as the api's PUBLIC_PORTAL_URL. PORTAL_BASE_URL: ${PORTAL_BASE_URL:-https://${PORTAL_HOST}} # Leave empty — see the panel service above. REKEY_COOKIE_SECURE: ${REKEY_COOKIE_SECURE:-} labels: - traefik.enable=true # PORTAL_EXTRA_HOST_RULES routes Applications' verified custom domains # here too, and Traefik's certificate resolver then asks for a # certificate for each Host() it names. Unset, only PORTAL_HOST is # routed. Written as Traefik rule text that follows the first Host(): # PORTAL_EXTRA_HOST_RULES= || Host(`billing.yourapp.com`) || Host(`account.other.com`) # The portal answers 404 on any host that is not a verified domain. - traefik.http.routers.rekey-portal.rule=Host(`${PORTAL_HOST:?set PORTAL_HOST in .env, e.g. portal.example.com}`)${PORTAL_EXTRA_HOST_RULES:-} - traefik.http.routers.rekey-portal.entrypoints=websecure - traefik.http.routers.rekey-portal.tls.certresolver=letsencrypt - traefik.http.services.rekey-portal.loadbalancer.server.port=3050 # Proxy secret (see PORTAL_PROXY_SECRET above). - traefik.http.middlewares.rekey-portal-proxy-secret.headers.customrequestheaders.X-Rekey-Proxy-Secret=${PORTAL_PROXY_SECRET} - traefik.http.routers.rekey-portal.middlewares=rekey-portal-proxy-secret networks: dokploy-network: {} rekey-edge: ipv4_address: ${REKEY_PORTAL_EDGE_IP:-10.203.53.11} volumes: rekey_postgres: rekey_valkey: networks: dokploy-network: external: true # Private to this stack — keeps Postgres/Redis (and api↔panel traffic) off the # shared network so generic names can't collide with other Dokploy apps. rekey-internal: driver: bridge # Private to this stack: the panel and portal reach the API over it from # fixed addresses, which is what lets the API trust their forwarded client # IP without trusting anything on dokploy-network. The subnet sits outside # Docker's default pools; if it collides with a network on your host, set # REKEY_EDGE_SUBNET and the two REKEY_*_EDGE_IP addresses inside it. rekey-edge: driver: bridge ipam: config: - subnet: ${REKEY_EDGE_SUBNET:-10.203.53.0/28}