Three misconfigurations in a row were each diagnosed the same slow way: deploy, try to sign in, read a status code, guess, read a response body, guess again. That is a property of the design, not of bad luck. Every required variable is read lazily, on the first request that needs it, so a container missing its environment starts clean, serves the sign-in page, and passes a healthcheck while being completely unable to authenticate anybody. The lazy reads have to stay — reading at module scope fails `next build`, which collects page data with NODE_ENV=production and none of these variables set (that is the "Failed to collect page data for /api/assistant" failure already documented in apiClient). So the check goes in an instrumentation hook instead, which Next runs once per server start and NEVER during a build: it returns early when NEXT_PHASE is 'phase-production-build', in server/lib/router-utils/instrumentation-globals.external.js. Boot now either prints what it resolved: [loyaly] config ok — platform https://mcp.loyaly.ai, auth secret set, NODE_ENV=production or refuses to start, naming every problem at once rather than one per deploy: refusing to start — 2 configuration problem(s): 1. LOYALY_API_BASE is invalid: https://api.example.com is not a supported production API host... 2. AUTH_SECRET is required in production — it signs the session cookie... Verified against a real standalone build, booted four ways: unconfigured, fully configured, LOYALY_API_BASE pointed at the console, and both wrong at once. The platform origin and its validation move to shared/config/platformApi. This is load-bearing, not tidying: apiClient is `server-only`, and that package resolves to a module which THROWS ON IMPORT outside a react-server condition — which the instrumentation bundle is not. Importing apiClient from the boot check would have crashed every start, correctly configured or not. The extracted module imports nothing but ConfigError, so the boot check and the request path run the same function against the same allowlist. Also corrects a claim I made in the Dockerfile two commits ago. `next build` DOES copy .env into .next/standalone — writeStandaloneDirectory takes exactly .env and .env.production and nothing else — so the explicit COPY is redundant rather than required. It stays, with an accurate reason: it only happens when .env is in the build context, and .dockerignore excluded it until recently. Keeping the COPY makes that dependency fail the Docker build loudly instead of producing an unconfigured image. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
94 lines
3.9 KiB
Docker
94 lines
3.9 KiB
Docker
# syntax=docker/dockerfile:1
|
|
|
|
# Stage 1: Install dependencies
|
|
FROM node:22-alpine AS deps
|
|
RUN apk add --no-cache libc6-compat
|
|
WORKDIR /app
|
|
|
|
COPY package.json package-lock.json ./
|
|
# devDeps are required to build (typescript, tailwind, eslint-config-next).
|
|
# This whole stage is discarded — none of it reaches the runner.
|
|
RUN npm ci --no-audit --no-fund
|
|
|
|
# Stage 2: Build the Next.js application
|
|
FROM node:22-alpine AS builder
|
|
WORKDIR /app
|
|
COPY --from=deps /app/node_modules ./node_modules
|
|
COPY . .
|
|
|
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
ENV NODE_ENV=production
|
|
# Each Docker build starts from a clean layer, so Turbopack's .next/cache is
|
|
# written but never restored. Skipping it cuts ~20% of build CPU (the metric
|
|
# that matters on a 1-vCPU host) and 116 MB off this layer.
|
|
ENV CI_BUILD=1
|
|
|
|
RUN npm run build
|
|
|
|
# Stage 3: Production runner with Next.js Standalone
|
|
FROM node:22-alpine AS runner
|
|
RUN apk add --no-cache libc6-compat
|
|
WORKDIR /app
|
|
|
|
ENV NODE_ENV=production
|
|
ENV NEXT_TELEMETRY_DISABLED=1
|
|
ENV PORT=3000
|
|
ENV HOSTNAME="0.0.0.0"
|
|
|
|
# ── Runtime configuration ────────────────────────────────────────────────
|
|
#
|
|
# Two variables are required to SERVE a request. Neither is required to BUILD:
|
|
# LOYALY_API_BASE is resolved on first use rather than at module load (see
|
|
# apiClient.ts) and sessionToken/tokenStore derive their key per call, so page
|
|
# data collection reads neither.
|
|
#
|
|
# LOYALY_API_BASE the Behavision API origin — https://mcp.loyaly.ai
|
|
# (NOT platform.loyaly.ai, which serves this console)
|
|
# → SHIPPED, in the .env copied below. Not a secret.
|
|
#
|
|
# AUTH_SECRET signs the session cookie and encrypts the platform token
|
|
# bundle. Generate with: openssl rand -base64 48
|
|
# → NOT shipped. Set it as a Dokploy secret.
|
|
#
|
|
# AUTH_SECRET used to be an ENV line here with a literal value, which put a
|
|
# session-forging key in git: anyone who could read the repo could mint a
|
|
# cookie for any user, and every built image carried it in a layer that
|
|
# `docker history` prints. Docker's own linter flags the pattern
|
|
# (SecretsUsedInArgOrEnv). It is gone; rotate the old value. That is why the
|
|
# split above exists — "inject everything" also meant injecting the one value
|
|
# that is public knowledge, and forgetting it took the console down.
|
|
|
|
# Run as a non-root user; nextjs owns nothing it does not need to write.
|
|
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
|
|
|
|
# Copy public static assets and standalone build output.
|
|
# These three paths are the ENTIRE runtime payload (~57 MB). Never copy the
|
|
# whole .next/ directory here — .next/dev and .next/cache are build-host-only
|
|
# and account for ~1.96 GB.
|
|
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
|
|
# The production environment, as a file the server reads at boot.
|
|
#
|
|
# Deliberately redundant, and worth keeping. `next build` already copies .env
|
|
# (and .env.production, and nothing else — see writeStandaloneDirectory in
|
|
# next/dist/build/index.js) into .next/standalone, so the line above lands one
|
|
# at /app/.env on its own. But it only does that when .env was in the BUILD
|
|
# CONTEXT, and .dockerignore excluded it until recently — which is precisely
|
|
# how images shipped with no LOYALY_API_BASE at all.
|
|
#
|
|
# This line turns that silent outcome into a loud one: exclude .env again and
|
|
# the Docker build FAILS here with "file not found" instead of producing an
|
|
# unconfigured image that starts and then rejects every sign-in.
|
|
#
|
|
# It does not pin the deployment either way: @next/env never overwrites a
|
|
# variable already present in process.env, so anything set in Dokploy wins.
|
|
COPY --chown=nextjs:nodejs .env ./.env
|
|
|
|
USER nextjs
|
|
|
|
EXPOSE 3000
|
|
|
|
CMD ["node", "server.js"]
|