# syntax=docker/dockerfile:1.7 # ============================================================================= # Dockerfile - multi-stage build for PowerDNS-AuthAdmin # # Stages: # builder - full deps install, Next.js production build, AND pre-bundle # of the three boot-time TS scripts into self-contained ESM # files. Three artefacts feed the runner: # 1. `.next/standalone` - Next's traced server bundle # (incl. the runtime node_modules subset Next computed). # 2. `.next/static` + `public` - static assets. # 3. `boot/{migrate,seed,provision}.js` - esbuild-bundled # ESM versions of the .ts scripts, every `lib/*` import # inlined. # # fs-prep - bookworm-slim layout-and-strip stage. Distroless has no # shell, no apt, no `mkdir`, no `strip`. So we assemble the # final filesystem here (chown, strip native .node binaries # to drop debug symbols, prepare /data) and COPY the prepared # tree as a single layer into the distroless runner. # # runner - `gcr.io/distroless/nodejs24-debian12:nonroot`. Non-root by # default (uid 65532 `nonroot`). NO shell, NO package manager, # NO source tree, NO tsx, NO separate prod-deps node_modules. # Boot bundles run with `node` directly. Externals (native # bindings, pg, pino transports) resolve from the standalone # bundle's already-traced node_modules. # # Critical: builder + fs-prep use Debian/glibc (NOT alpine/musl). napi-rs # native bindings (@node-rs/argon2, better-sqlite3) resolve to different # prebuilt binaries by libc; a builder on musl would install musl # binaries that can't be dlopen()'d by the glibc runner. Both bases # share the same glibc to keep the bindings portable. # # Trade-off (vs. bookworm-slim runner): no shell at runtime - incident # triage via `docker exec sh` is unavailable. If you need it, build # a separate `:debug` tag against bookworm-slim using the same builder. # ============================================================================= # --- Stage 1: builder -------------------------------------------------------- FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS builder RUN apt-get update && apt-get install -y --no-install-recommends \ python3 build-essential ca-certificates \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY package.json package-lock.json ./ # `npm ci` only - it installs strictly from the committed lockfile, so the # build is reproducible and every dependency version is hash-pinned via # package-lock.json. No `npm install` fallback: the lockfile is committed, # and an image that silently un-pinned-installs on a missing lock would be # a supply-chain regression (OpenSSF Scorecard "Pinned-Dependencies"). RUN --mount=type=cache,target=/root/.npm \ npm ci --include=optional COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production # Two outputs from the same source tree: # • npm run build → .next/standalone (Next.js production server) # • npm run build:boot → boot/{migrate,seed,provision}.js (esbuild) RUN npm run build && npm run build:boot # --- Stage 2: fs-prep -------------------------------------------------------- # Distroless has no shell to run mkdir / chown / strip. We assemble the final # tree here under bookworm-slim where we DO have those tools. FROM node:24-bookworm-slim@sha256:242549cd46785b480c832479a730f4f2a20865d61ea2e404fdb2a5c3d3b73ecf AS fs-prep WORKDIR /stage COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/boot ./boot COPY --from=builder /app/drizzle ./drizzle COPY --from=builder /app/drizzle-sqlite ./drizzle-sqlite COPY --from=builder /app/docker/entrypoint.mjs ./entrypoint.mjs # Strip debug symbols from every .node native binding we ship # (better-sqlite3, @node-rs/argon2). Sub-MB each but free; the stripped # binaries dlopen identically. binutils is purged before COPY so none of # it lands in the distroless runner. RUN apt-get update && apt-get install -y --no-install-recommends binutils \ && find . -name '*.node' -exec strip --strip-unneeded {} \; \ && apt-get purge -y binutils \ && apt-get autoremove -y \ && rm -rf /var/lib/apt/lists/* # Distroless `nonroot`: uid 65532, gid 65532. RUN chown -R 65532:65532 /stage && \ mkdir -p /data-stage && chown -R 65532:65532 /data-stage # --- Stage 3: runner (distroless) ------------------------------------------- # Digest-pinned like the builder/fs-prep stages above (supply-chain # reproducibility; OpenSSF Scorecard "Pinned-Dependencies"). This is the # multi-arch index digest for :nonroot - bump it alongside the base-image # refresh that updates the node:24-bookworm-slim digests. FROM gcr.io/distroless/nodejs24-debian12:nonroot@sha256:14d42e2511532589a7c7e01a753667a74fcc96266e137e8125006b87b0c32d0a AS runner ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 ENV NEXT_TELEMETRY_DISABLED=1 # Distroless puts `node` at /nodejs/bin/node and runs it as the image's # ENTRYPOINT, but does NOT add /nodejs/bin to $PATH. Compose health- # checks of the form `CMD ["node", ...]` bypass ENTRYPOINT and exec # `node` directly from PATH - they need it visible there. ENV PATH=/nodejs/bin:$PATH # Build provenance surfaced in the UI (sidebar version chip + GitHub/Docs # links, see lib/app-meta.ts). The CI docker job sets GIT_SHA to the # commit and APP_RELEASE=true only for vX.Y.Z tag builds. Both default # empty/false for a plain `docker build`, which makes the app fall back # to local-dev labels and main-branch links. ARG GIT_SHA="" ARG APP_RELEASE="false" ENV APP_GIT_SHA=$GIT_SHA ENV APP_RELEASE=$APP_RELEASE WORKDIR /app COPY --from=fs-prep --chown=nonroot:nonroot /stage/ /app/ COPY --from=fs-prep --chown=nonroot:nonroot /data-stage /data USER nonroot EXPOSE 3000 # Image-level liveness probe, same shape as the compose services' healthcheck # (distroless has no curl/wget; `node` is on PATH per the ENV above). Compose # `healthcheck:` blocks and Kubernetes liveness/readiness probes override or # ignore this respectively, so it only governs plain `docker run` deployments, # where it is the only thing that lets `docker ps` show the app as unhealthy. # `/healthz` is the liveness endpoint; `/readyz` (DB + migrations) is left to # orchestrators that can act on readiness separately. HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ CMD ["node", "-e", "fetch('http://127.0.0.1:3000/healthz').then(r=>r.ok?process.exit(0):process.exit(1)).catch(()=>process.exit(1))"] # Distroless/nodejs ships with `node` as the entrypoint; the CMD is the # script path. Same effect as `node entrypoint.mjs` on bookworm-slim. CMD ["entrypoint.mjs"]