From befe4307c3935c7a5b2ddcff2727dc631636eee1 Mon Sep 17 00:00:00 2001 From: Aravind Date: Thu, 17 Sep 2026 18:55:53 +0530 Subject: [PATCH] fix(deploy): ship the production environment instead of injecting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .dockerignore | 13 +++++++++-- .env | 56 +++++++++++++++++++++++++++++++++++++++++++++++ .env.example | 28 +++++++++++++++++++----- .gitignore | 24 ++++++++++++++------ Dockerfile | 39 +++++++++++++++++++++++---------- scripts/bundle.sh | 17 +++++++++++++- 6 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 .env diff --git a/.dockerignore b/.dockerignore index 338ba8a..432c2e7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -30,8 +30,17 @@ dist *.tsbuildinfo next-env.d.ts -# Secrets — injected at runtime, never baked into a layer -.env +# Environment. +# +# `.env` IS copied in (see the Dockerfile's runner stage) — it holds the +# production platform host, which is not a secret, and is what the standalone +# server reads at boot. Excluding it is what shipped an image with no +# LOYALY_API_BASE and made every BFF call fail. +# +# `.env.*` stays out, and that exclusion is load-bearing: a developer's +# `.env.local` points LOYALY_API_BASE at http://127.0.0.1:8088, and @next/env +# loads `.env.local` AHEAD of `.env`. One leaked into the image and the +# deployed console calls localhost — silently, with no error to read. .env.* *.pem diff --git a/.env b/.env new file mode 100644 index 0000000..78bd394 --- /dev/null +++ b/.env @@ -0,0 +1,56 @@ +# --------------------------------------------------------------------------- +# Production runtime configuration. COMMITTED ON PURPOSE — carries no secret. +# --------------------------------------------------------------------------- +# +# This file is the production environment. It is read by `next build` and, more +# importantly, by the standalone `server.js` at boot (Next calls loadEnvConfig +# on the server's working directory), so the deployed container knows the +# platform host without anyone remembering to type it into a dashboard. +# +# ── Precedence, exactly as @next/env resolves it ──────────────────────────── +# +# 1. real process.env (Dokploy / docker -e / systemd) ← always wins +# 2. .env.production.local +# 3. .env.local ← LOCAL DEV ONLY. Never enters the image. +# 4. .env.production +# 5. .env ← this file, the floor everything falls back to +# +# A value already present in process.env is never overwritten by a file, so +# setting LOYALY_API_BASE in Dokploy still overrides this — nothing here locks +# the deployment in. It only removes "unset" as a possible state. +# +# ── Working on this locally? ──────────────────────────────────────────────── +# Put your overrides in `.env.local` (gitignored, loaded ahead of this file). +# Without one, `npm run dev` will talk to the PRODUCTION platform, because that +# is what this file says. `.env.example` has the local values to copy. + +# The one shared Loyaly platform API (Behavision). Server-side only and +# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass +# the BFF, which is what keeps the access token out of JavaScript. +# +# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing +# the variable there makes the BFF call its own origin, which fails in a way +# that looks like a broken login form rather than a misconfiguration. +# apiClient.ts rejects that hostname by name for exactly this reason. +LOYALY_API_BASE=https://mcp.loyaly.ai + +# Browser → this app's own BFF routes, which are same-origin. Empty is correct +# and is what makes the console work on any hostname it is served from: +# requests go to /api/... on whatever origin loaded the page (localhost:3100 in +# dev, platform.loyaly.ai in production) and the server hop above reaches the +# platform. Setting this to the platform host would send the browser straight +# at the API with no session cookie and no token — do not. +# +# It is NEXT_PUBLIC, so it is inlined at BUILD time, not read at runtime. +# Changing it in Dokploy's environment panel would do nothing without a rebuild. +NEXT_PUBLIC_API_BASE= + +# AUTH_SECRET is deliberately NOT in this file. +# +# It signs the session cookie and encrypts the platform token bundle, so a +# value committed here is a session-forging key in git — anyone who can read +# the repo could mint a cookie for any user. It was already removed from the +# Dockerfile once for that reason; do not reintroduce it here. +# +# Set it as a Dokploy environment variable / secret. Production refuses to sign +# sessions without it. Generate with: openssl rand -base64 48 diff --git a/.env.example b/.env.example index c5e3c34..5313604 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,37 @@ +# --------------------------------------------------------------------------- +# Template for `.env.local` — your LOCAL overrides. Copy it: +# +# cp .env.example .env.local +# +# Do not copy it to `.env`. `.env` is committed and already holds the +# production values; `.env.local` is loaded ahead of it and is gitignored. +# --------------------------------------------------------------------------- + # The one shared Loyaly platform API (Behavision). Server-side only and # deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass # the BFF, which is what keeps the access token out of JavaScript. # -# local dev http://127.0.0.1:8088 -# production https://mcp.loyaly.ai +# local dev http://127.0.0.1:8088 ← what belongs in .env.local +# production https://mcp.loyaly.ai ← already set in the committed .env # # NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing # the variable there makes the BFF call its own origin, which fails in a way # that looks like a broken login form rather than a misconfiguration. # -# There is no fallback: production refuses to start without this set. +# There is no remote fallback: production refuses to serve without this set. +# That is why it is committed in `.env` rather than left to a dashboard. LOYALY_API_BASE=http://127.0.0.1:8088 # Signs the session cookie and encrypts the platform token bundle. -# Required in production — the app refuses to start signing sessions with the -# development key. Generate with: openssl rand -base64 48 +# +# The ONLY variable that is a real secret, and the only one production takes +# solely from the environment — it is in no committed file, by design. Set it +# as a Dokploy environment variable / secret. Locally, any string works; leave +# it blank and a development key is used. +# +# Generate with: openssl rand -base64 48 AUTH_SECRET= -# Browser → this app's own BFF routes. Same origin, so normally left empty. +# Browser → this app's own BFF routes. Same origin, so leave it empty. Inlined +# at BUILD time (NEXT_PUBLIC), so changing it at runtime does nothing. NEXT_PUBLIC_API_BASE= diff --git a/.gitignore b/.gitignore index 7e3d960..9b0122f 100644 --- a/.gitignore +++ b/.gitignore @@ -31,13 +31,23 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) -.env* -# ...except the template, which carries no secret and is the only record of -# which variables the app needs. `.env*` was swallowing it too, so a fresh -# clone got no guidance at all — while AUTH_SECRET and LOYALY_API_BASE are both -# mandatory in production and the app refuses to start without them. -!.env.example +# env files. +# +# `.env*` used to be blanket-ignored, and that was the deploy bug: the image +# shipped with no LOYALY_API_BASE at all, so every BFF call died on "required +# in production — refusing to guess the Loyaly platform host" and the console +# looked like a broken login form. The production host is not a secret, so it +# now lives in a committed `.env` and ships with the build. +# +# What stays ignored is the per-machine and per-secret layer: +.env.local +.env.*.local +# +# What is committed: +# .env production defaults (no secret) — loaded by the running server +# .env.example the template, the record of which variables exist +# +# AUTH_SECRET belongs in neither. It is injected by Dokploy at runtime. # vercel .vercel diff --git a/Dockerfile b/Dockerfile index 9dbb1f5..f8b2c8f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,27 +35,28 @@ ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" -# ── Runtime configuration: supplied by the orchestrator, never baked in ── +# ── Runtime configuration ──────────────────────────────────────────────── # -# Two variables are REQUIRED at runtime and are deliberately absent from this -# image. Set them as Dokploy environment variables / secrets: +# 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 -# LOYALY_API_BASE the Behavision API origin — https://mcp.loyaly.ai -# (NOT platform.loyaly.ai, which serves this console) +# → 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. -# -# Neither is needed 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 never reads either one. Both are -# read on the first request that needs them, and a missing one fails loudly -# there instead of silently guessing a host or a key. +# (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 @@ -68,6 +69,20 @@ 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 diff --git a/scripts/bundle.sh b/scripts/bundle.sh index f103472..e7e603b 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -27,6 +27,14 @@ for p in .next/standalone .next/static public; do fi done +# .env is the production environment, not a secret — LOYALY_API_BASE lives in +# it and the server reads it at boot. It is tracked in git, so a missing one +# means the tree is wrong, not that this deploy opted out. +if [ ! -f .env ]; then + echo "error: .env missing — it is committed; restore it with 'git checkout .env'" >&2 + exit 1 +fi + rm -rf "$STAGE" mkdir -p "$STAGE" @@ -37,6 +45,10 @@ mkdir -p "$STAGE/.next" cp -R .next/static "$STAGE/.next/static" cp -R public "$STAGE/public" +# Beside server.js, which is where Next's loadEnvConfig looks. NOT .env.local — +# that is the dev override and would point the deployed server at 127.0.0.1. +cp .env "$STAGE/.env" + TARBALL="$OUT/loyaly-mer-login.tar.gz" rm -f "$TARBALL" tar -czf "$TARBALL" -C "$STAGE" . @@ -46,4 +58,7 @@ echo "bundle: $(du -sh "$STAGE" | cut -f1) ($STAGE)" echo "tarball: $(du -sh "$TARBALL" | cut -f1) ($TARBALL)" echo echo "deploy: scp $TARBALL :/srv/ && tar -xzf loyaly-mer-login.tar.gz -C /srv/app" -echo "run: PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production node server.js" +echo "run: AUTH_SECRET=... PORT=3000 HOSTNAME=0.0.0.0 NODE_ENV=production node server.js" +echo +echo "note: LOYALY_API_BASE ships in the bundled .env. AUTH_SECRET does not —" +echo " it signs sessions and must come from the host's environment."