The deployed console called its own origin instead of the platform. The BFF
route would throw "LOYALY_API_BASE is required in production — refusing to
guess the Loyaly platform host", and from the browser that reads as a broken
login form rather than as a missing variable.
The guard was right; nothing ever set the variable. `.gitignore` had a blanket
`.env*` and `.dockerignore` excluded `.env` and `.env.*`, so the image carried
no environment at all and the only copy of the production host was a comment
in `.env.example`. Injecting it by hand at the orchestrator was the single
point of failure, and it failed.
The platform host is not a secret, so it is now committed in `.env` and copied
into the runner stage. `next build` does not fold `.env` into
`.next/standalone`, which is why the COPY is explicit; server.js chdirs to
/app and Next runs loadEnvConfig there, so the file sits beside it at the
WORKDIR root. `npm run bundle` stages it the same way for a non-Docker deploy.
This pins nothing. @next/env never overwrites a variable already present in
process.env, so anything set in Dokploy still wins — verified against
@next/env directly: a bare image resolves https://mcp.loyaly.ai, an injected
LOYALY_API_BASE overrides it, and a leaked .env.local beats both.
That last case is why `.dockerignore` still excludes `.env.*`. A developer's
.env.local points at http://127.0.0.1:8088 and loads AHEAD of .env, so one
leaking into the build context would make the deployed console call localhost
with no error to read. Confirmed the context now carries `.env` and nothing
else.
AUTH_SECRET stays out of every committed file and out of the image. It signs
the session cookie and encrypts the token bundle, so a committed value is a
session-forging key in git — the thing 8b3fbab removed from the Dockerfile.
It remains a Dokploy secret, and production still refuses to sign without it.
`.env.example` is now the template for `.env.local` rather than a second copy
of the production values, so the two files cannot drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
91 lines
3.7 KiB
Docker
91 lines
3.7 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.
|
|
#
|
|
# `next build` does NOT fold .env into .next/standalone — the standalone output
|
|
# carries server.js and traced node_modules, nothing else — so without this line
|
|
# the running container has no LOYALY_API_BASE and every upstream call throws
|
|
# "required in production". server.js chdirs to /app and Next calls
|
|
# loadEnvConfig on it, which is why the file belongs beside server.js at the
|
|
# WORKDIR root and not under .next/.
|
|
#
|
|
# This does not pin the deployment: @next/env never overwrites a variable that
|
|
# is already in process.env, so anything set in Dokploy still wins over this
|
|
# file. It only removes "unset" from the set of possible states.
|
|
COPY --chown=nextjs:nodejs .env ./.env
|
|
|
|
USER nextjs
|
|
|
|
EXPOSE 3000
|
|
|
|
CMD ["node", "server.js"]
|