# syntax=docker/dockerfile:1.7 # next-app multi-stage Docker build. # # builder : install dev + prod deps, run `next build`. Bun resolves the # workspace standalone (next-app is NOT a root workspace # member since P4.1.0 to avoid Turbopack hoisting issues). # runner : copy the standalone bundle + .next/static + public assets, # run with raw `node server.js`. Bun is only used for the # build phase; the runtime is plain Node 22-alpine because # standalone server.js has no Bun-specific runtime needs and # keeps the image small. # # Build: docker compose build next-app # Image: ~120-150MB final layer (vs ~600MB without standalone) FROM oven/bun:1.3.14-alpine AS deps WORKDIR /app COPY next-app/package.json next-app/bun.lock ./ # --frozen-lockfile pins to bun.lock exactly; fails the build if the # lockfile is stale instead of silently resolving to newer versions. RUN bun install --frozen-lockfile FROM oven/bun:1.3.14-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY next-app/ ./ # Disable Next telemetry at build time. Production opt-out lives in the # runner stage too. ENV NEXT_TELEMETRY_DISABLED=1 # Sentry build-time variables. NEXT_PUBLIC_SENTRY_DSN MUST be available # at build because Next inlines NEXT_PUBLIC_* into the client bundle — # runtime env is too late, the bundle is already compiled. Default "" # means a build without these args still succeeds (Sentry SDK no-ops on # falsy DSN). SENTRY_AUTH_TOKEN drives source-map upload via # next.config.ts's `sourcemaps.disable: !process.env.SENTRY_AUTH_TOKEN` # gate — without it, the upload step skips and the build still passes. # # SENTRY_ORG / SENTRY_PROJECT are the other half of that gate and were the # reason a token alone would not have worked: next.config.ts does not name # an org or project, so the plugin resolves both from the environment. With # the token set and these two absent, the upload has a credential and no # destination — it fails at build rather than skipping, which is the loud # half of a footgun whose quiet half (no token at all) had been hiding it. ARG NEXT_PUBLIC_SENTRY_DSN= ARG SENTRY_AUTH_TOKEN= ARG SENTRY_ORG= ARG SENTRY_PROJECT= ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_ORG=$SENTRY_ORG ENV SENTRY_PROJECT=$SENTRY_PROJECT # The AniList image mirror switch (src/lib/images/mirror.ts): the URL prefix # AniList image URLs are rewritten to, https://animegoclub.com/img/anilist/ in # production. NEXT_PUBLIC_* for the same reason as the DSN above -- Next # inlines it into both bundles at build time, so only a build arg reaches it. # Empty (the default) keeps every image on AniList's own URLs, which is what a # build without the nginx mirror in front of it needs. ARG NEXT_PUBLIC_IMAGE_MIRROR_BASE= ENV NEXT_PUBLIC_IMAGE_MIRROR_BASE=$NEXT_PUBLIC_IMAGE_MIRROR_BASE # The commit this image was built from. next.config.ts turns it into # NEXT_PUBLIC_BUILD_ID, which is how a tab opened before a deploy learns it # is stale. Empty is fine and falls back to a timestamp -- the only thing # lost is that rebuilding the same commit then reads as a new deployment. ARG GIT_SHA= ENV GIT_SHA=$GIT_SHA # P6.6 pin: --webpack. Next 16's turbopack default deadlocks on the # Library + Player port surface (23k LOC + 4.5 MB jassub WASM + worker # URLs in usePlaybackSession). Webpack mode compiles in ~15 s. Drop # the flag when turbopack stabilises asset-handling for those paths. RUN bun --bun next build --webpack FROM node:22-alpine AS runner WORKDIR /app ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME=0.0.0.0 # Non-root user for runtime. UID 1001 matches the node:alpine convention # used by Next.js official samples. RUN addgroup --system --gid 1001 nodejs \ && adduser --system --uid 1001 nextjs # `output: "standalone"` produces .next/standalone with a minimal # server.js + only the deps server-side actually imports. Static assets # and the public dir must be copied alongside since standalone does NOT # bundle them. COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/public ./public # .next/cache/images is a named volume (docker-compose.yml). A new named volume # takes its owner from the image's directory at the mount point, so the # directory has to exist here, owned by the runtime user; otherwise the volume # is created root-owned and the image optimizer cannot write its cache. RUN mkdir -p .next/cache/images && chown -R nextjs:nodejs .next/cache USER nextjs EXPOSE 3000 # Use 127.0.0.1 instead of localhost: localhost resolves to ::1 first on # node:alpine, but next 16 standalone server only listens on the IPv4 # wildcard (HOSTNAME=0.0.0.0). wget then connects to v6, fails, marks # the container unhealthy even though every external request works. HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \ CMD wget -qO- http://127.0.0.1:3000/api/healthz >/dev/null 2>&1 || exit 1 CMD ["node", "server.js"]