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:
@@ -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
56
.env
Normal 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
|
||||||
28
.env.example
28
.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
|
# 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
24
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
39
Dockerfile
39
Dockerfile
@@ -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
|
||||||
|
|||||||
@@ -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."
|
||||||
|
|||||||
Reference in New Issue
Block a user