fix(deploy): ship the production environment instead of injecting it

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>
This commit is contained in:
2026-09-17 18:55:53 +05:30
parent 8b3fbab7a0
commit befe4307c3
6 changed files with 149 additions and 28 deletions

View File

@@ -30,8 +30,17 @@ dist
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Secrets — injected at runtime, never baked into a layer # Environment.
.env #
# `.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.* .env.*
*.pem *.pem

56
.env Normal file
View File

@@ -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

View File

@@ -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 # The one shared Loyaly platform API (Behavision). Server-side only and
# deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass # deliberately NOT NEXT_PUBLIC: publishing the host would let a browser bypass
# the BFF, which is what keeps the access token out of JavaScript. # the BFF, which is what keeps the access token out of JavaScript.
# #
# local dev http://127.0.0.1:8088 # local dev http://127.0.0.1:8088 ← what belongs in .env.local
# production https://mcp.loyaly.ai # production https://mcp.loyaly.ai ← already set in the committed .env
# #
# NOT platform.loyaly.ai — that host serves THIS console, not the API. Pointing # 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 # 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. # 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 LOYALY_API_BASE=http://127.0.0.1:8088
# Signs the session cookie and encrypts the platform token bundle. # 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= 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= NEXT_PUBLIC_API_BASE=

24
.gitignore vendored
View File

@@ -31,13 +31,23 @@ yarn-debug.log*
yarn-error.log* yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files.
.env* #
# ...except the template, which carries no secret and is the only record of # `.env*` used to be blanket-ignored, and that was the deploy bug: the image
# which variables the app needs. `.env*` was swallowing it too, so a fresh # shipped with no LOYALY_API_BASE at all, so every BFF call died on "required
# clone got no guidance at all — while AUTH_SECRET and LOYALY_API_BASE are both # in production — refusing to guess the Loyaly platform host" and the console
# mandatory in production and the app refuses to start without them. # looked like a broken login form. The production host is not a secret, so it
!.env.example # 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
.vercel .vercel

View File

@@ -35,27 +35,28 @@ ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000 ENV PORT=3000
ENV HOSTNAME="0.0.0.0" 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 # Two variables are required to SERVE a request. Neither is required to BUILD:
# image. Set them as Dokploy environment variables / secrets: # 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 # AUTH_SECRET signs the session cookie and encrypts the platform token
# bundle. Generate with: openssl rand -base64 48 # bundle. Generate with: openssl rand -base64 48
# LOYALY_API_BASE the Behavision API origin — https://mcp.loyaly.ai # → NOT shipped. Set it as a Dokploy secret.
# (NOT platform.loyaly.ai, which serves this console)
# #
# AUTH_SECRET used to be an ENV line here with a literal value, which put a # 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 # 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 # cookie for any user, and every built image carried it in a layer that
# `docker history` prints. Docker's own linter flags the pattern # `docker history` prints. Docker's own linter flags the pattern
# (SecretsUsedInArgOrEnv). It is gone; rotate the old value. # (SecretsUsedInArgOrEnv). It is gone; rotate the old value. That is why the
# # split above exists — "inject everything" also meant injecting the one value
# Neither is needed to BUILD. LOYALY_API_BASE is resolved on first use rather # that is public knowledge, and forgetting it took the console down.
# 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.
# Run as a non-root user; nextjs owns nothing it does not need to write. # 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 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/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 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 USER nextjs
EXPOSE 3000 EXPOSE 3000

View File

@@ -27,6 +27,14 @@ for p in .next/standalone .next/static public; do
fi fi
done 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" rm -rf "$STAGE"
mkdir -p "$STAGE" mkdir -p "$STAGE"
@@ -37,6 +45,10 @@ mkdir -p "$STAGE/.next"
cp -R .next/static "$STAGE/.next/static" cp -R .next/static "$STAGE/.next/static"
cp -R public "$STAGE/public" 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" TARBALL="$OUT/loyaly-mer-login.tar.gz"
rm -f "$TARBALL" rm -f "$TARBALL"
tar -czf "$TARBALL" -C "$STAGE" . 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 "tarball: $(du -sh "$TARBALL" | cut -f1) ($TARBALL)"
echo echo
echo "deploy: scp $TARBALL <host>:/srv/ && tar -xzf loyaly-mer-login.tar.gz -C /srv/app" echo "deploy: scp $TARBALL <host>:/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."