docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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:<port>`
# 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 /<slug> — no per-app secret key. Enable an app's
# portal in Panel → Application → Billing → Portal, then visit
# http://localhost:3050/<slug>. 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}