From 9532e6d472513808bdf761224e4c096745c5ba26 Mon Sep 17 00:00:00 2001 From: f-amine Date: Thu, 18 Jun 2026 13:38:28 +0100 Subject: [PATCH] fix(docker): make prod images actually build and boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three bugs in docker/Dockerfile.next, none caught by CI (CI builds via pnpm, never through the Dockerfile): 1. NEXT_PUBLIC_* are inlined at build time, but env_file only reaches runtime, so `next build` died on missing NEXT_PUBLIC_APP_URL. Pass the public vars as build args (compose interpolates them from the deploy env via a shared YAML anchor) and set them as ENV in the builder. Server vars get shape-valid placeholders at build only (real secrets arrive at runtime via env_file; the runner is a separate stage, so no secret enters any image layer). 2. `COPY ... public ... 2>/dev/null || true` is not shell — Docker COPY parsed the redirect as a literal source and failed once the build got far enough to reach it. web/admin ship no public/ dir, so mkdir -p it in the builder and make the COPY unconditional. 3. CMD was exec-form JSON with ${APP_NAME}, which Docker does not expand → "Cannot find module apps/${APP_NAME}/server.js". Persist APP_NAME as ENV and use `sh -c 'exec node ...'` so it expands and node still receives SIGTERM. Verified locally: web + marketing images build; web container boots (Next ready, /api/health serves) with only runtime env. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker-compose.prod.yml | 18 +++++++++++++++ docker/Dockerfile.next | 50 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 65 insertions(+), 3 deletions(-) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c9486c3..e9e3a12 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -2,6 +2,21 @@ # Includes apps + Postgres + Redis + nightly R2 backup. # Dev should use docker-compose.yml (infra only). +# NEXT_PUBLIC_* are inlined into the client bundle at build time, so they must +# be passed as build args (env_file only reaches runtime). Interpolated from +# the deploy env (Dokploy Environment tab / .env). Shared across all 3 apps. +x-public-build-args: &public-build-args + NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} + NEXT_PUBLIC_WEB_APP_URL: ${NEXT_PUBLIC_WEB_APP_URL:-} + NEXT_PUBLIC_MARKETING_URL: ${NEXT_PUBLIC_MARKETING_URL:-} + NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-} + NEXT_PUBLIC_POSTHOG_KEY: ${NEXT_PUBLIC_POSTHOG_KEY:-} + NEXT_PUBLIC_POSTHOG_HOST: ${NEXT_PUBLIC_POSTHOG_HOST:-} + NEXT_PUBLIC_GA_ID: ${NEXT_PUBLIC_GA_ID:-} + NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-} + NEXT_PUBLIC_BRAND_NAME: ${NEXT_PUBLIC_BRAND_NAME:-vibestack} + NEXT_PUBLIC_SUPPORT_EMAIL: ${NEXT_PUBLIC_SUPPORT_EMAIL:-} + services: postgres: image: postgres:17-alpine @@ -48,6 +63,7 @@ services: context: . dockerfile: docker/Dockerfile.next args: + <<: *public-build-args APP_NAME: web APP_PORT: 3001 restart: unless-stopped @@ -73,6 +89,7 @@ services: context: . dockerfile: docker/Dockerfile.next args: + <<: *public-build-args APP_NAME: marketing APP_PORT: 3000 restart: unless-stopped @@ -91,6 +108,7 @@ services: context: . dockerfile: docker/Dockerfile.next args: + <<: *public-build-args APP_NAME: admin APP_PORT: 3002 restart: unless-stopped diff --git a/docker/Dockerfile.next b/docker/Dockerfile.next index b9bcd47..4f80abc 100644 --- a/docker/Dockerfile.next +++ b/docker/Dockerfile.next @@ -20,8 +20,48 @@ RUN pnpm install --frozen-lockfile FROM base AS builder ARG APP_NAME ENV NEXT_TELEMETRY_DISABLED=1 + +# NEXT_PUBLIC_* are inlined into the client bundle at build time, so they must +# carry the REAL production values here (env_file only reaches runtime). They +# arrive as build args from docker-compose.prod.yml, which interpolates them +# from the deploy env. All optional except NEXT_PUBLIC_APP_URL. +ARG NEXT_PUBLIC_APP_URL +ARG NEXT_PUBLIC_WEB_APP_URL +ARG NEXT_PUBLIC_MARKETING_URL +ARG NEXT_PUBLIC_ADMIN_URL +ARG NEXT_PUBLIC_POSTHOG_KEY +ARG NEXT_PUBLIC_POSTHOG_HOST +ARG NEXT_PUBLIC_GA_ID +ARG NEXT_PUBLIC_SENTRY_DSN +ARG NEXT_PUBLIC_BRAND_NAME +ARG NEXT_PUBLIC_SUPPORT_EMAIL +ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \ + NEXT_PUBLIC_WEB_APP_URL=$NEXT_PUBLIC_WEB_APP_URL \ + NEXT_PUBLIC_MARKETING_URL=$NEXT_PUBLIC_MARKETING_URL \ + NEXT_PUBLIC_ADMIN_URL=$NEXT_PUBLIC_ADMIN_URL \ + NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY \ + NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST \ + NEXT_PUBLIC_GA_ID=$NEXT_PUBLIC_GA_ID \ + NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN \ + NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME \ + NEXT_PUBLIC_SUPPORT_EMAIL=$NEXT_PUBLIC_SUPPORT_EMAIL + +# Server vars are read from the container env at RUNTIME (env_file), never +# inlined. The build only needs shape-valid placeholders so env validation and +# module-load code (e.g. `new URL(APP_URL)` in @vibestack/auth) don't throw. +# These live only in the builder stage — the runner is a separate FROM and +# gets real values from env_file. No real secrets enter any image layer. +ENV DATABASE_URL=postgresql://build:build@localhost:5432/build \ + BETTER_AUTH_SECRET=build_time_placeholder_secret_min_32_chars \ + BETTER_AUTH_URL=http://localhost:3001 \ + APP_URL=http://localhost:3001 \ + CORS_ORIGIN=http://localhost:3001 + COPY --from=deps /repo /repo -RUN pnpm --filter "./apps/${APP_NAME}" run build +# Guarantee a public/ dir exists (web/admin ship none) so the runner COPY +# below is unconditional — Dockerfile COPY has no shell-style optional copy. +RUN pnpm --filter "./apps/${APP_NAME}" run build \ + && mkdir -p "apps/${APP_NAME}/public" FROM node:22-alpine AS runner ARG APP_NAME @@ -29,15 +69,19 @@ ARG APP_PORT ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=${APP_PORT} +# Persist APP_NAME so the runtime CMD can resolve the standalone server path. +ENV APP_NAME=${APP_NAME} WORKDIR /app RUN addgroup -S nodejs && adduser -S nextjs -G nodejs # Next.js standalone output COPY --from=builder --chown=nextjs:nodejs /repo/apps/${APP_NAME}/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /repo/apps/${APP_NAME}/.next/static ./apps/${APP_NAME}/.next/static -COPY --from=builder --chown=nextjs:nodejs /repo/apps/${APP_NAME}/public ./apps/${APP_NAME}/public 2>/dev/null || true +COPY --from=builder --chown=nextjs:nodejs /repo/apps/${APP_NAME}/public ./apps/${APP_NAME}/public USER nextjs EXPOSE ${APP_PORT} ENV HOSTNAME=0.0.0.0 -CMD ["node", "apps/${APP_NAME}/server.js"] +# Shell form so $APP_NAME expands at runtime; `exec` hands PID 1 to node so it +# receives SIGTERM for graceful shutdown. Exec-form JSON would not expand vars. +CMD ["sh", "-c", "exec node apps/$APP_NAME/server.js"]