docker-compose.prod.yml
Rama main · réplica de lectura del repositorio original.
# 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
# `<base>/login/oauth/<provider>/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 <this deployment>" — 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>/api/v1/mcp/<slug>`.
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 <PORTAL_HOST>/<slug>.
# 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}