FROM node:22.23.4-alpine AS base RUN apk add --no-cache libc6-compat openssl postgresql-client bash ffmpeg # Install dependencies only when needed FROM base AS deps WORKDIR /app COPY package.json package-lock.json* ./ COPY prisma ./prisma/ COPY prisma.config.ts ./prisma.config.ts # npm ci runs the prepare script, which patches next for next-ws. RUN npm ci # Rebuild the source code only when needed FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . # Generate Prisma client RUN npx prisma generate ENV NEXT_TELEMETRY_DISABLED=1 # Server action IDs are hashed from this key, the action's file path and its # name. Without one, Next makes a random key for every build, so each release # gives every action a new ID and a tab opened before the deploy fails its next # save. The same key on every build keeps the IDs, and those tabs keep working. # Optional: without the secret the build makes its own key, as before. Next # writes the key into the build output, so it is not secret once built; never # reuse the key a deployment sets at run time (NEXT_SERVER_ACTIONS_ENCRYPTION_KEY). RUN --mount=type=secret,id=next_actions_key \ if [ -s /run/secrets/next_actions_key ]; then \ export NEXT_SERVER_ACTIONS_ENCRYPTION_KEY="$(cat /run/secrets/next_actions_key)"; \ fi && \ npm run build # What the container runs beside the server: the Prisma CLI for the migrations # at start, and tsx with a full Prisma client for the demo seed. Installed in # a folder of its own, with no package.json beside it. Run inside /app, where # the standalone build keeps the project's package.json, the same install # pulled in every production dependency the project has, replaced the patched # next the build had traced, and made the image well over a gigabyte larger. FROM base AS tools WORKDIR /tools RUN npm install --no-save --no-audit --no-fund \ prisma@7.6.0 @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0 pg dotenv tsx # restic, for the scheduled backups a workshop sends to its own storage. The # release binary is fetched for the platform being built and checked against a # pinned SHA-256, so a changed or tampered download fails the build instead of # ending up in the image. Bumping the version means bumping both sums with it. FROM base AS restic ARG TARGETARCH ARG RESTIC_VERSION=0.19.1 RUN set -e; \ case "$TARGETARCH" in \ amd64) sum=f415415624dcc452f2a02b8c33641791a8c6d6d3b65bbb3543fcf9a25151585c ;; \ arm64) sum=a5f64aaab53d51e311fa3829124c5b703f2d14cf187d8640b6be3b2b49376465 ;; \ *) echo "no pinned restic checksum for TARGETARCH '${TARGETARCH}'"; exit 1 ;; \ esac; \ wget -q -O /restic.bz2 \ "https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_linux_${TARGETARCH}.bz2"; \ echo "$sum /restic.bz2" | sha256sum -c -; \ bunzip2 /restic.bz2; \ chmod 755 /restic # Production image, copy all the files and run next FROM base AS runner WORKDIR /app ARG APP_VERSION=development ENV APP_VERSION=${APP_VERSION} ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 --home /home/nextjs nextjs # Copy public assets COPY --from=builder /app/public ./public # Set correct permissions for prerender cache RUN mkdir .next && chown nextjs:nodejs .next # Copy standalone build COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Copy generated Prisma client COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./src/generated # The server runs on the node_modules Next traced for it. That is exactly what # the build ran with: next carrying the next-ws patch the prepare script # applied in the deps stage, and sharp with the binary for # this image's platform. Nothing is installed over it, so none of that has to # be patched or resolved a second time here. # # The traced node_modules is not a full install, though: several packages are # a package.json with their entry point pruned away, and a plain Node script # run in /app dies on a missing module. So what runs beside the server lives # in /app/tools with its own complete node_modules: the migrations (see # init-db.sh) and the demo seed (see .github/workflows/deploy-demo.yml), each # next to the files it reads. COPY --from=tools --chown=nextjs:nodejs /tools/node_modules ./tools/node_modules COPY --from=builder --chown=nextjs:nodejs /app/prisma ./tools/prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./tools/prisma.config.ts COPY --from=builder --chown=nextjs:nodejs /app/src/generated ./tools/src/generated # Fail the build here rather than at the first live update or certificate # download. WebSocket routes only work on a next that carries the next-ws # patch, with next-ws waiting for the lazily loaded route module; a native # module that cannot be loaded throws while its route is being evaluated, # which reaches the browser as an empty HTTP 500 with nothing to explain it. RUN grep -qs "next-ws" node_modules/next/dist/server/next-server.js \ || { echo "the traced next is missing the next-ws patch"; exit 1; } # next-ws 2.2.19 waits for the route module itself (ensureUserland); before # that, scripts/ carried a patch for it, and an older next-ws must not slip back in. RUN grep -qs "ensureUserland" node_modules/next-ws/dist/server/index.cjs \ || { echo "the traced next-ws does not wait for the lazily loaded route module"; exit 1; } RUN node -e "require('sharp'); console.log('sharp loads')" # Copy init script COPY --chown=nextjs:nodejs init-db.sh ./init-db.sh RUN chmod +x ./init-db.sh # restic runs as a child of the server for scheduled backups. Run it once here # so a binary that cannot start on this platform fails the build, not the # first backup a workshop relies on. COPY --from=restic /restic /usr/local/bin/restic RUN restic version # Create the data directories: uploads, the local backup repositories (the # compose file mounts a volume there) and restic's cache RUN mkdir -p /app/data/uploads /app/data/backups /app/data/restic-cache \ && chown -R nextjs:nodejs /app/data USER nextjs EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" ENTRYPOINT ["./init-db.sh"] CMD ["node", "server.js"]