Ctrl K
ComunidadGuías…

docker-compose.prod.yml

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

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