# Full Rekey stack — postgres + redis + api + panel + portal. # # The api/panel/portal services are behind the `full` profile, so: # - `docker compose up -d` → just postgres + redis (local dev: # run the apps from your shell) # - `docker compose --profile full up -d` → the whole stack # # Usage: # cp .env.example .env (then edit secrets — JWT_SECRET, ENCRYPTION_KEY, …) # docker compose --profile full up -d --build # # Every published port here binds to 127.0.0.1. This is the LOCAL stack; the # internet-facing one is docker-compose.prod.yml (Traefik, no published ports). # To expose this file anyway, set BIND_ADDRESS=0.0.0.0 — and set # OPERATOR_SIGNUP_MODE=invite at the same time, or the first stranger to find # the host can create an operator account. # # Database migrations run automatically on api boot (the api-runtime image runs # `prisma migrate deploy` before starting the server) — no manual step needed. services: postgres: image: postgres:16-alpine restart: unless-stopped logging: driver: json-file options: { max-size: "10m", max-file: "3" } environment: POSTGRES_USER: rekey # No default. An unset POSTGRES_PASSWORD fails the container start with a # clear Postgres error, which is the right outcome: the previous default # of `rekey` shipped a known credential on every deployment. POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env (openssl rand -hex 24)} POSTGRES_DB: rekey ports: # Bound to loopback, NOT 0.0.0.0. Only the api container needs Postgres and # it reaches it over the compose network by service name; publishing on all # interfaces put the database on the public internet for anyone deploying # to a VPS without a host firewall. Loopback keeps `pnpm dev` against # dockerised datastores working. - "127.0.0.1:5432:5432" volumes: - rekey_postgres:/var/lib/postgresql/data healthcheck: test: ["CMD-SHELL", "pg_isready -U rekey -d rekey"] interval: 5s timeout: 5s retries: 10 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)}", ] ports: # Loopback only — same reasoning as Postgres above. An unauthenticated # Redis on a public interface is a trivially readable session/lockout store. - "127.0.0.1:6379:6379" volumes: - rekey_valkey:/data environment: # valkey-cli reads this, so the healthcheck below doesn't put the password # in a command line that shows up in `docker inspect`/`ps` output. REDISCLI_AUTH: ${REDIS_PASSWORD} healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 5s retries: 10 api: profiles: ["full"] # only starts with `docker compose --profile full up` logging: driver: json-file options: { max-size: "10m", max-file: "3" } build: context: . target: api-runtime image: rekey-api:local depends_on: postgres: { condition: service_healthy } redis: { condition: service_healthy } environment: NODE_ENV: production DATABASE_URL: postgresql://rekey:${POSTGRES_PASSWORD}@postgres:5432/rekey?schema=public REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 # No default for this or SUPER_ADMIN_KEY. The old `change-me-in-prod` # placeholder is 17 characters, the API requires 32, so its only possible # outcome was a crash-looping container. `:?` fails the same way at # `docker compose up`, earlier, and names the variable. JWT_SECRET: "${JWT_SECRET:?set JWT_SECRET in .env (openssl rand -hex 32)}" # No default, deliberately. This key is AES-256-GCM over every provider # credential, OAuth client secret, TOTP seed, SMTP password and RS256 # private key in the database — a published default means anyone holding # a stolen dump can decrypt all of it. Compose fails the interpolation if # it is unset, which is the intended outcome. # Quoted: the `:?` message contains a colon-space, which YAML reads as a # nested mapping and rejects. Unquoted, this made every `docker compose` # command on the repo fail — including quickstart step one. ENCRYPTION_KEY: "${ENCRYPTION_KEY:?generate one with openssl rand -hex 32}" SUPER_ADMIN_KEY: "${SUPER_ADMIN_KEY:?set SUPER_ADMIN_KEY in .env (openssl rand -hex 32)}" API_URL: http://api:3030 # Public panel origin — the operator MCP OAuth authorize endpoint # redirects here (/mcp-consent). MUST be the public panel URL in a real # deploy or operator-MCP sign-in redirects to the wrong host. PANEL_URL: ${PANEL_URL:-http://localhost:3031} # Browser origins allowed to call the API with cookies (the panel). CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:3031} # Public, internet-reachable API origin for provider webhook URLs. Set in # a real deploy (e.g. https://api.yourdomain.com); empty falls back to API_URL. PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-} # Local hosted-portal origin, auto-allowed for publishable-key calls and # for portal password-reset links. Same origin as the portal's # PORTAL_BASE_URL below, or portal resets fail with AUTH_URL_NOT_ALLOWED. PUBLIC_PORTAL_URL: ${PUBLIC_PORTAL_URL:-http://localhost:3050} # Optional. The same value as the panel's and portal's below. Needed for # the portal to sign in customers of an Application with # deviceBinding: required. openssl rand -hex 32 INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:-} # Who can create operator (admin) accounts: open | invite | closed. # # `open` is kept as the default because first-boot has to be able to # create the first operator, and every documented first-boot path # (README, docs/quickstart.md §3 "Prefer clicking?") is self-serve # sign-up in the panel. It is only safe because the ports below are # loopback-bound by default: `open` + a published 0.0.0.0 port meant the # first stranger to portscan the host could claim an operator account on # somebody else's deployment. # # If you set BIND_ADDRESS=0.0.0.0 to expose this stack, set this to # `invite` (or `closed`) in the same edit. The API logs a warning at boot # when it finds `open` with NODE_ENV=production. OPERATOR_SIGNUP_MODE: ${OPERATOR_SIGNUP_MODE:-open} # Starting ceilings for every workspace this deployment creates (JSON, # TenantLimitsSchema — e.g. {"maxProductionApps":1}). Empty = unlimited, # which is the default and what every workspace gets today. DEFAULT_TENANT_LIMITS: ${DEFAULT_TENANT_LIMITS:-} # Whether a signed-in operator may create additional workspaces: # open (default) | disabled. Creation only — switching and listing are # never gated. WORKSPACE_CREATION: ${WORKSPACE_CREATION:-open} # Operator MCP server (agent read + operate). On by default; false to disable. OPERATOR_MCP_ENABLED: ${OPERATOR_MCP_ENABLED:-true} # RFC 7591 dynamic client registration on the OPERATOR MCP authorization # server: open (default) | disabled. Open is what lets Claude Desktop / # Claude Code / Cursor connect without you hand-registering a client, and # is the documented flow. Set `disabled` once your clients are connected: # an anonymous registration's only prize is an ALLOWLISTED redirect_uri, # which is the one ingredient a consent-phishing link needs. OPERATOR_MCP_DYNAMIC_REGISTRATION: ${OPERATOR_MCP_DYNAMIC_REGISTRATION:-open} # Believe X-Forwarded-For from the panel and portal containers ONLY (their # fixed addresses on the rekey-edge network below), so operator sign-in limits key on the # operator's real IP instead of the panel's one address, which let anyone # spraying the login page lock every operator out. Not a hop count: the # API port is published, and a hop count would believe the header from # any caller that reaches it. If you expose the panel itself with # BIND_ADDRESS=0.0.0.0 and NO proxy in front of it, set # TRUSTED_PROXIES=false: the panel forwards whatever X-Forwarded-For it # was sent, and with no proxy of yours in between, the client wrote it. # docs/rate-limits.md. TRUSTED_PROXIES: ${TRUSTED_PROXIES:-${REKEY_PANEL_EDGE_IP:-10.203.53.10},${REKEY_PORTAL_EDGE_IP:-10.203.53.11}} networks: [default, rekey-edge] ports: # Loopback by default, like postgres and redis above — but for a # different reason. The datastores are loopback because nothing outside # this compose network should ever reach them. The API genuinely does # need to be reachable; what it should not do is become reachable by # accident, on a file whose every documented URL is `localhost:` # and whose OPERATOR_SIGNUP_MODE default is `open`. Somebody bringing # this stack up on a VPS to try it published an unauthenticated # operator-signup endpoint on the internet without choosing to. # # The internet-facing path is docker-compose.prod.yml, which publishes no # ports at all and routes through Traefik on hostnames you supply. # If you nonetheless want THIS file exposed, say so: # BIND_ADDRESS=0.0.0.0 docker compose --profile full up -d # and set OPERATOR_SIGNUP_MODE=invite in the same breath. - "${BIND_ADDRESS:-127.0.0.1}:3030:3030" healthcheck: # Liveness, deliberately not /health/ready: restarting the API cannot fix # a Postgres or Redis outage, and a restart loop during one makes recovery # harder. Gate traffic on /health or /health/ready at the proxy 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 panel: profiles: ["full"] logging: driver: json-file options: { max-size: "10m", max-file: "3" } build: context: . target: panel-runtime image: rekey-panel:local depends_on: - api environment: NODE_ENV: production # In-cluster API address the panel calls server-side. REKEY_URL: http://api:3030 # Public API origin shown in the UI (e.g. the webhook URL operators paste # into Stripe). MUST be the public origin, not the in-cluster REKEY_URL. NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3030} PANEL_URL: ${PANEL_URL:-http://localhost:3031} # Published directly (no proxy in front), so no forwarded header is # believed and the panel reports the socket address to the API. PANEL_TRUSTED_PROXIES: ${PANEL_TRUSTED_PROXIES:-0} # Optional. Sent to the API as X-Rekey-Caller-Secret so it can believe the # client IP the panel forwards; must match the API's value. INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:-} # A fixed address on the rekey-edge network, so the API can trust exactly # this container's X-Forwarded-For (TRUSTED_PROXIES above) and nothing # else. The panel only talks to the API, so it needs no other network. networks: rekey-edge: ipv4_address: ${REKEY_PANEL_EDGE_IP:-10.203.53.10} ports: # Loopback by default — see the api service above. - "${BIND_ADDRESS:-127.0.0.1}:3031:3031" # Hosted multi-app customer portal (Portal V2). ONE deployment serves every # opted-in Application at / — no per-app secret key. Enable an app's # portal in Panel → Application → Billing → Portal, then visit # http://localhost:3050/. See docs/portal.md. portal: profiles: ["full"] logging: driver: json-file options: { max-size: "10m", max-file: "3" } build: context: . target: portal-runtime image: rekey-portal:local depends_on: - api environment: NODE_ENV: production REKEY_URL: http://api:3030 # Same origin as the api's PUBLIC_PORTAL_URL above. PORTAL_BASE_URL: http://localhost:3050 # Unset here: nothing sits in front of this portal, so any # X-Forwarded-For it receives was written by the client. Set it to the # number of proxies in front if you add one. docs/rate-limits.md. PORTAL_TRUSTED_PROXY_HOPS: ${PORTAL_TRUSTED_PROXY_HOPS:-} # Optional. Sent to the API as X-Rekey-Caller-Secret; must match the # api service's value. INTERNAL_CALLER_SECRET: ${INTERNAL_CALLER_SECRET:-} networks: rekey-edge: ipv4_address: ${REKEY_PORTAL_EDGE_IP:-10.203.53.11} ports: # Loopback by default — see the api service above. - "${BIND_ADDRESS:-127.0.0.1}:3050:3050" volumes: rekey_postgres: rekey_valkey: networks: # The panel and portal network (see TRUSTED_PROXIES on the api service). A # fixed subnet because the API trusts two fixed addresses on it, chosen # outside Docker's default address pools (172.17-31.x, 192.168.x). If it # collides with a network you already have (a second copy of this stack, # say), set REKEY_EDGE_SUBNET and the two REKEY_*_EDGE_IP addresses inside it. rekey-edge: ipam: config: - subnet: ${REKEY_EDGE_SUBNET:-10.203.53.0/28}