Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ jobs:
platforms: ${{ matrix.platform }}
provenance: false # Disable provenance to avoid unknown/unknown
sbom: false # Disable sbom to avoid unknown/unknown
# Reuse layers from previous runs, scoped per architecture so the two
# matrix jobs do not overwrite each other's cache. The dependency
# install is the slow layer and only changes when the lockfile does.
cache-from: type=gha,scope=${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.REPO }},push-by-digest=true,name-canonical=true,push=true

- name: Export digest
Expand Down
73 changes: 58 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,16 @@ COPY ./package.json \
COPY ./scripts ./scripts
COPY ./prisma ./prisma

# The registry occasionally resets a connection mid-install, which fails the
# whole image build for a reason that has nothing to do with the code. Retry a
# few times before giving up.
RUN apk add --no-cache openssl && \
npm ci --ignore-scripts
for i in 1 2 3 4 5; do \
npm ci --ignore-scripts --fetch-retries=5 --fetch-timeout=600000 && exit 0; \
echo "npm ci failed (attempt $i of 5), retrying in 10s..."; \
sleep 10; \
done; \
exit 1

COPY ./src ./src
COPY ./messages ./messages
Expand All @@ -27,30 +35,65 @@ ENV NEXT_TELEMETRY_DISABLED=1
COPY scripts/build.env .env
RUN npm run build

RUN rm -r .next/cache
# Next.js copies .env into the standalone output, which would ship the mocked
# build values (a database URL pointing at `db`, placeholder S3 and OpenAI
# credentials) inside the image. Real configuration comes from the container
# environment and would win, but a variable the operator *forgot* to set would
# silently resolve to a build placeholder instead of failing. Drop it.
RUN rm -f .next/standalone/.env

FROM node:24-alpine AS runtime-deps
# The standalone output traces its own dependencies, so the runtime stage no
# longer installs a production node_modules. What it does still need is the
# Prisma CLI, to run `migrate deploy` at container start — and the CLI is not
# part of the app's module graph, so nothing traces it.
#
# It gets its own isolated install rather than being copied out of the base
# stage: that stage installs with --ignore-scripts (the repo's postinstall runs
# migrate deploy, which cannot run at build time), so @prisma/engines never
# downloads the schema engine that `migrate deploy` needs. Installing the same
# version here, with scripts, produces a complete self-contained CLI.
FROM node:24-alpine AS prisma-cli

WORKDIR /usr/app
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs ./
COPY --from=base /usr/app/prisma ./prisma

# No `prisma generate` here: the generated client is bundled into .next by the
# build, and regenerating would need the source tree this stage does not have.
RUN npm ci --omit=dev --ignore-scripts
WORKDIR /opt/prisma-cli
ENV CHECKPOINT_DISABLE=1
RUN apk add --no-cache openssl
COPY --from=base /usr/app/node_modules/prisma/package.json ./_prisma.json
RUN PRISMA_VERSION="$(node -p "require('./_prisma.json').version")" && \
rm -f ./_prisma.json && \
npm init -y > /dev/null && \
for i in 1 2 3 4 5; do \
npm install --no-audit --no-fund --fetch-retries=5 \
--fetch-timeout=600000 "prisma@${PRISMA_VERSION}" && exit 0; \
echo "prisma install failed (attempt $i of 5), retrying in 10s..."; \
sleep 10; \
done; \
exit 1

FROM node:24-alpine AS runner

EXPOSE 3000/tcp
WORKDIR /usr/app

ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# The standalone server binds to localhost by default, which is unreachable
# from outside the container.
ENV HOSTNAME=0.0.0.0
ENV PORT=3000

RUN apk add --no-cache openssl

# The traced server, plus the two things tracing cannot know about: the static
# assets it serves and the public/ directory.
COPY --from=base /usr/app/.next/standalone ./
COPY --from=base /usr/app/.next/static ./.next/static
COPY ./public ./public

# prisma.config.ts carries the connection URLs that used to live in
# schema.prisma; `prisma migrate deploy` reads it at container start.
COPY --from=base /usr/app/package.json /usr/app/package-lock.json /usr/app/next.config.mjs /usr/app/prisma.config.ts ./
COPY --from=runtime-deps /usr/app/node_modules ./node_modules
COPY ./public ./public
COPY ./scripts ./scripts
COPY --from=base /usr/app/prisma ./prisma
COPY --from=base /usr/app/.next ./.next
COPY --from=base /usr/app/prisma.config.ts ./
COPY --from=prisma-cli /opt/prisma-cli/node_modules ./node_modules
COPY ./scripts ./scripts

ENTRYPOINT ["/bin/sh", "/usr/app/scripts/container-entrypoint.sh"]
2 changes: 1 addition & 1 deletion compose.e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ services:
healthcheck:
# 127.0.0.1 rather than localhost: busybox may resolve localhost to ::1.
# /api/health/readiness runs `SELECT 1`, and scripts/container-entrypoint.sh
# runs `prisma migrate deploy` *before* `npm run start` -- so any 200 here
# runs `prisma migrate deploy` *before* starting the server -- so any 200 here
# proves the schema is migrated and the app is serving.
test:
[
Expand Down
4 changes: 4 additions & 0 deletions next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ if (process.env.S3_UPLOAD_ENDPOINT) {

/** @type {import('next').NextConfig} */
const nextConfig = {
// Emit a self-contained server into .next/standalone, containing only the
// files Next.js traced as actually reachable at runtime. The Docker runtime
// stage copies that instead of a full production `node_modules`.
output: 'standalone',
images: {
remotePatterns
},
Expand Down
8 changes: 6 additions & 2 deletions scripts/container-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,9 @@

set -euxo pipefail

npx prisma migrate deploy
exec npm run start
# Invoke the Prisma CLI by path: the standalone image has no package.json
# scripts and no .bin on PATH, so `npx prisma` would try to fetch it.
node node_modules/prisma/build/index.js migrate deploy

# The standalone build's own server entry point, in place of `next start`.
exec node server.js
Loading