# syntax=docker/dockerfile:1.7 # # Multi-stage build for Rekey (api + panel + portal + admin). # # Stages: # base — pnpm + corepack on a slim node image # deps — installs ALL workspace deps (including dev) for building # build — compiles api (tsc) + panel + portal + admin (next build) # runtime — copies built artifacts + production-only deps into a fresh # slim image. Each app is its own runtime stage; pick with `--target`. # # Build: # docker build -t rekey-api . (default = api) # docker build -t rekey-panel --target=panel-runtime \ # --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \ # --build-arg NEXT_PUBLIC_APP_URL=https://panel.example.com . # (NEXT_PUBLIC_* have no defaults — they compile into the bundle, so a default # would be a Rekey URL nobody could override at runtime. Marketing REQUIRES # both and its build fails without them, deliberately.) # docker build -t rekey-portal --target=portal-runtime . # # Or use docker-compose.yml which builds each with shared build cache. # # The api image runs `prisma migrate deploy` on start (idempotent) before # booting the server, so a fresh database is migrated automatically. ARG NODE_VERSION=24.22.1 # ─── base ───────────────────────────────────────────────────────────── FROM node:${NODE_VERSION}-slim AS base ENV PNPM_HOME=/pnpm ENV PATH=$PNPM_HOME:$PATH RUN corepack enable && corepack prepare pnpm@9.12.0 --activate RUN apt-get update -y && apt-get install -y --no-install-recommends openssl ca-certificates && rm -rf /var/lib/apt/lists/* WORKDIR /app # ─── deps ───────────────────────────────────────────────────────────── FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./ COPY prisma ./prisma COPY apps/api/package.json apps/api/ COPY apps/panel/package.json apps/panel/ COPY apps/portal/package.json apps/portal/ COPY packages/shared-types/package.json packages/shared-types/ COPY packages/sdk-node/package.json packages/sdk-node/ COPY packages/sdk-react/package.json packages/sdk-react/ COPY packages/sdk-nextjs/package.json packages/sdk-nextjs/ COPY packages/cli/package.json packages/cli/ COPY packages/mcp/package.json packages/mcp/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile # ─── build ──────────────────────────────────────────────────────────── FROM deps AS build COPY . . # Generate Prisma client + build everything via turbo RUN pnpm --filter @rekey.dev/api exec prisma generate --schema ../../prisma/schema.prisma RUN pnpm --filter @rekey.dev/shared-types build # SDK packages must be built before the apps that import them from dist: the # hosted portal imports @rekey.dev/react; @rekey.dev/nextjs (examples) depends on # node + react. panel and portal use raw fetch, so they need none of these. # Order matters: nextjs depends on node + react. RUN pnpm --filter @rekey.dev/node build RUN pnpm --filter @rekey.dev/react build RUN pnpm --filter @rekey.dev/nextjs build RUN pnpm --filter @rekey.dev/api build # NEXT_PUBLIC_* are baked into client bundles at BUILD time, so they must be # declared before any Next.js app builds (they once sat after the panel build, # so only marketing ever saw them). # # None of these has a default, deliberately. A default here is a Rekey-owned # value compiled into the bundle, which cannot then be overridden at runtime — # the self-host compose passes no build args, so a default silently shipped # `api.rekey.dev` into every self-hosted panel and made the "unset" handling in # the app unreachable. Our own deploy supplies them in # docker-compose.{panel,marketing}.yml; self-hosters pass their own or get the # app's explicit unconfigured state. ARG NEXT_PUBLIC_API_URL= ARG NEXT_PUBLIC_APP_URL= # No default, deliberately. NEXT_PUBLIC_GA_MEASUREMENT_ID must never default to # Rekey's own property (a self-hosted panel would ship its operators' # behaviour to us). Pass it as a build arg when you build the panel # image. ARG NEXT_PUBLIC_GA_MEASUREMENT_ID= # The panel sidebar's Discord row. No default, for the same reason as GA. ARG NEXT_PUBLIC_DISCORD_INVITE_URL= # Names the "Continue with …" button when this deployment signs operators in # against one of its own Applications. No default: a self-hoster's button must # not say "Rekey.dev", and the app falls back to a generic label when unset. ARG NEXT_PUBLIC_PANEL_OAUTH_REKEY_LABEL= RUN NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ NEXT_PUBLIC_GA_MEASUREMENT_ID=$NEXT_PUBLIC_GA_MEASUREMENT_ID \ NEXT_PUBLIC_PANEL_OAUTH_REKEY_LABEL=$NEXT_PUBLIC_PANEL_OAUTH_REKEY_LABEL \ NEXT_PUBLIC_DISCORD_INVITE_URL=$NEXT_PUBLIC_DISCORD_INVITE_URL \ pnpm --filter @rekey.dev/panel build RUN pnpm --filter @rekey.dev/portal build # ─── api-runtime ────────────────────────────────────────────────────── FROM base AS api-runtime ENV HOST=0.0.0.0 ENV PORT=3030 WORKDIR /app # Install the api + shared-types dependency closure. We do NOT pass --prod and # we set NODE_ENV=production only AFTER installing, so pnpm keeps the api's # devDependencies — specifically the `prisma` CLI, needed to (a) generate the # client and (b) run `migrate deploy` at boot. (`prisma generate` auto-tries # `pnpm add prisma -D` when prisma is absent from devDependencies, which fails # inside a workspace — so prisma must be a present devDependency, as in build.) COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY prisma ./prisma COPY apps/api/package.json apps/api/ COPY packages/shared-types/package.json packages/shared-types/ # Install deps, generate the Prisma client, and prune dev-only tooling — all in # ONE layer. Reasons: # * No --prod / NODE_ENV unset here so pnpm keeps `prisma` (a devDependency), # needed to generate the client and run `migrate deploy` at boot. (`prisma # generate` auto-runs `pnpm add prisma -D` when prisma is missing from # devDependencies, which fails inside a workspace — so it must be present.) # * Generating here (not COPYing from build) avoids the pnpm peer-hash skew # that leaves a cross-stage client where the runtime symlink can't see it # ("@prisma/client did not initialize"). # * The TypeScript/test/build toolchain (vitest, typescript, tsx, esbuild, # @types) is the heaviest devDep weight and the runtime never imports it. # Deleting it in the SAME RUN as the install keeps it out of the layer # entirely (a delete in a later layer wouldn't shrink the image). Prisma + # its engines stay. RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ pnpm install --frozen-lockfile --filter @rekey.dev/api... --filter @rekey.dev/shared-types \ && pnpm --filter @rekey.dev/api exec prisma generate --schema ../../prisma/schema.prisma \ && rm -rf \ node_modules/.pnpm/typescript@* node_modules/.pnpm/@types+* \ node_modules/.pnpm/vitest@* node_modules/.pnpm/@vitest+* \ node_modules/.pnpm/vite@* node_modules/.pnpm/vite-node@* node_modules/.pnpm/jsdom@* \ node_modules/.pnpm/tsx@* node_modules/.pnpm/esbuild@* node_modules/.pnpm/@esbuild+* \ node_modules/.pnpm/rollup@* node_modules/.pnpm/@rollup+* \ node_modules/.pnpm/turbo@* node_modules/.pnpm/@turbo+* \ node_modules/.pnpm/lightningcss* node_modules/.pnpm/tailwindcss@* node_modules/.pnpm/@tailwindcss+* # Built artifacts COPY --from=build /app/packages/shared-types/dist packages/shared-types/dist COPY --from=build /app/apps/api/dist apps/api/dist # The git commit this image was built from, reported by /health/live next to # the release version (apps/api/src/lib/build-info.ts). Optional: unset reads # as `unknown`. Declared this late so a new commit invalidates only these two # lines, not the dependency install above. ARG REKEY_COMMIT= ENV REKEY_COMMIT=$REKEY_COMMIT ENV NODE_ENV=production # libuv's threadpool runs argon2 hashing AND dns.lookup (every outbound # webhook, breached-password check and email resolves its host there). At the # default of 4, a burst of sign-ins held every thread and outbound calls # queued behind it. apps/api/src/lib/passwords.ts caps hashing at 4 of these # 16, so hashing throughput and memory are unchanged and 12 stay free. It must # be set before the process starts: libuv reads it once, on first use. ENV UV_THREADPOOL_SIZE=16 EXPOSE 3030 # Drop root — the runtime only reads node_modules/dist (world-readable) and # writes nothing to disk (migrations go to the DB over the network). The `node` # user (uid 1000) ships in the base image. USER node # Apply any pending migrations (handles a fresh DB on first boot; idempotent), # then start. `prisma` CLI ships in node_modules, so this needs no network. # `exec node …` so the node process REPLACES the shell and becomes PID 1 — it # then receives the orchestrator's SIGTERM directly. Without `exec`, the shell # is PID 1 and doesn't forward the signal, so the API's graceful-shutdown # handler never fires and the final request-log batch is lost on every deploy. CMD ["sh", "-c", "pnpm --filter @rekey.dev/api exec prisma migrate deploy --schema ../../prisma/schema.prisma && exec node apps/api/dist/index.js"] # ─── panel-runtime ──────────────────────────────────────────────────── FROM base AS panel-runtime ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3031 WORKDIR /app # Next standalone output: a self-contained server.js plus only the traced # node_modules. No pnpm install, no full dependency tree. The bundle is rooted # at the workspace root (outputFileTracingRoot), so it lands at /app as-is. COPY --from=build --chown=node:node /app/apps/panel/.next/standalone ./ # Static assets and public/ (logo, brand fonts) aren't part of standalone, so # copy them to the paths Next expects. COPY --from=build --chown=node:node /app/apps/panel/.next/static apps/panel/.next/static COPY --from=build --chown=node:node /app/apps/panel/public apps/panel/public # The commit this image was built from, shown in the sidebar next to the API's # (apps/panel/src/lib/build-info.ts). Unset reads as `unknown`. ARG REKEY_COMMIT= ENV REKEY_COMMIT=$REKEY_COMMIT EXPOSE 3031 # Run as the unprivileged `node` user. --chown above gives it ownership of the # app tree so Next can write its runtime cache. USER node CMD ["node", "apps/panel/server.js"] # ─── portal-runtime ─────────────────────────────────────────────────── # Hosted multi-app customer portal (Portal V2) at port 3050. Same Next # standalone pattern as panel-runtime — copies the traced bundle in, no pnpm # install. ONE deployment serves every opted-in Application, resolved by the # in the URL (portal.rekey.dev/). It holds NO per-app secret key: # it identifies each app by its publishable key + authorizes users with their # own token. Needs only REKEY_URL (+ PORTAL_BASE_URL). See docs/portal.md. FROM base AS portal-runtime ENV NODE_ENV=production ENV HOSTNAME=0.0.0.0 ENV PORT=3050 WORKDIR /app # Next standalone output (see panel-runtime). Bundle is rooted at the # workspace root, so it unpacks to /app directly. COPY --from=build --chown=node:node /app/apps/portal/.next/standalone ./ # Static assets aren't part of standalone — copy them to the path Next # expects. (portal has no public/ dir, same as panel.) COPY --from=build --chown=node:node /app/apps/portal/.next/static apps/portal/.next/static EXPOSE 3050 # Run as the unprivileged `node` user. --chown above gives it ownership of the # app tree so Next can write its runtime cache. USER node CMD ["node", "apps/portal/server.js"]