Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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}