fix(deploy): harden runtime configuration

Production answered 502 on every url, /favicon.ico included, because the
container had no AUTH_SECRET and the boot check called process.exit(1): the
container died, so Traefik had no upstream and the one line explaining it was
trapped inside a restart-looping container. The exit is already gone (0dc865b).
This makes the configuration contract itself hard to get wrong.

One required variable, one resolver, two probe endpoints.

shared/config/authSecret.ts is now the only place the signing secret is
resolved. sessionToken.ts (HMAC of the identity cookie) and tokenStore.ts
(AES-256-GCM key for the platform token bundle) each read process.env
independently before, under rules that disagreed — one accepted a
whitespace-only value the other rejected. It accepts AUTH_SECRET, or
AUTH_SECRET_FILE for the Docker/Swarm secret convention when a dashboard field
mangles a value, trims both, and is a pure function of the environment.

That purity is load-bearing. Next 16 compiles proxy.ts for the NODE runtime
(its own docs: "Proxy defaults to using the Node.js runtime"), confirmed in the
build output — the proxy is in .next/server/chunks, not .next/server/edge. But
the proxy entry and the route entries are still SEPARATE BUNDLES with their own
copy of this module, so a secret invented in module scope would differ between
them, the proxy would reject every cookie the login route signed, and /login
would redirect forever. There is no generated fallback and there must not be.

LOYALY_API_BASE is no longer required in production. It accepted exactly one
origin, so an unset value could never have meant another, and requiring it added
a failure mode without adding a choice. Verified against the installed @next/env:
a real variable set to the EMPTY STRING is left empty and .env is NOT consulted,
so one blank dashboard field defeated the value shipped in the image and took
production down with "required in production". Any other host set explicitly is
still rejected by name, platform.loyaly.ai included.

/api/health and /api/ready are split. Health was returning 503 on a
configuration fault — readiness semantics on the name every orchestrator probes
by default. A Dockerfile HEALTHCHECK pointed there for one commit, and because
Dokploy runs applications as Swarm services, Swarm removed the task from the
load balancer and rescheduled it: the container was up, serving a 503 that named
the fault, and nothing could reach it to read that 503. Health is now liveness
and always 200 while the process answers; ready is readiness and 503 while a
variable is missing, for a DEPLOY gate (Order start-first + FailureAction
rollback) where failing keeps the previous good task serving. No HEALTHCHECK is
reintroduced.

Diagnostics answer the question that could not be answered from outside the
container: whether the variable never arrived or arrived empty, the secret's
source and length (never its value), and any environment variable whose NAME is
a near-miss for AUTH_SECRET — wrapped (NEXT_PUBLIC_AUTH_SECRET) or mistyped
(AUTH_SECERT, via bounded edit distance). Dokploy's Build Arguments and Build
Secrets are build-time only and absent at runtime, which from inside the
container is indistinguishable from never setting it; the boot log now tells
those apart.

Dockerfile, .env and .env.example changes are comments only — every directive
and every variable value is byte-identical to before.

Verified on the standalone payload the image ships: absent / empty / whitespace
/ typo'd name / wrong API host all keep the container ALIVE and answering 503
with x-loyaly-config: misconfigured; a valid secret gives / 307, /login 200,
/favicon.ico 200, health 200, ready 200. Cross-bundle auth, for both sources: a
cookie signed with the live secret is accepted by the proxy bundle (200) and
independently re-verified by the app/layout.tsx render bundle, while one signed
with a different secret is rejected by both (307). tsc --noEmit clean, eslint
clean on changed files, production build exit 0.

This does not by itself end the outage: AUTH_SECRET still has to be set on the
container, in Dokploy's runtime Environment Variables panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 00:22:11 +05:30
parent 0dc865ba66
commit 781757d377
12 changed files with 481 additions and 129 deletions

View File

@@ -37,41 +37,62 @@ 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.
# EXACTLY ONE variable must be supplied to this container. That is the whole
# deployment contract, and it is one because everything else either ships in
# the image or can only have one legal value.
#
# AUTH_SECRET signs the session cookie and encrypts the platform token
# bundle. Generate with: openssl rand -hex 32
# → NOT shipped. Set it as a Dokploy secret.
# → NOT shipped, and never can be: a secret in the image is
# readable with `docker history`, and a secret in git is a
# session-forging key for anyone who can read the repo.
# → Set it in Dokploy → Environment (the RUNTIME panel — a
# BUILD argument is not present when the server runs), or
# mount it and set AUTH_SECRET_FILE to its path.
#
# Hex rather than base64 on purpose. `openssl rand -base64 48` ends in '=' and
# may contain '+' and '/'. Pasted into a dashboard field or a KEY=VALUE env
# editor that splits on the first '=', that value can be stored truncated — or
# not at all — and the result is indistinguishable from never having set it.
# Hex is [0-9a-f] only, so there is nothing for a parser to mangle. 32 bytes is
# 256 bits, more than the HMAC and the AES-256 key derived from it need.
# AUTH_SECRET_FILE optional alternative: a path to read the secret from, the
# standard Docker/Swarm secret convention. AUTH_SECRET wins
# when both are set. Use this when a dashboard field mangles
# the value.
#
# A container started WITHOUT AUTH_SECRET no longer dies. It boots, prints the
# missing variable on stderr, answers 503 with `x-loyaly-config: misconfigured`
# on every request, and fails the HEALTHCHECK below. Exiting instead is what
# made this fault present as a bare 502 Bad Gateway from the reverse proxy on
# every url — /favicon.ico first, in the browser console — with the one line
# that explained it trapped inside a restart-looping container. See
# src/instrumentation-node.ts.
# LOYALY_API_BASE no longer required. Production accepts exactly one origin
# (https://mcp.loyaly.ai), so an unset variable could never
# have meant anything else; shared/config/platformApi now
# resolves it to that origin. Setting it to any OTHER host
# is still rejected by name. It also still ships in the .env
# copied below, which keeps `docker run` self-describing.
#
# 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.
# Hex rather than base64 for the secret, on purpose. `openssl rand -base64 48`
# ends in '=' and may contain '+' and '/'. Pasted into a dashboard field or a
# KEY=VALUE editor that splits on the first '=', that value can be stored
# truncated — or not at all — and the result is indistinguishable from never
# having set it. Hex is [0-9a-f] only, so there is nothing for a parser to
# mangle. 32 bytes is 256 bits, more than the HMAC and the AES-256 key derived
# from it need.
#
# A container started without the secret does not die and does not 502. It
# boots, names the missing variable on stderr (including any environment
# variable whose NAME looks like a near-miss for AUTH_SECRET, which is the one
# cause invisible from a dashboard), and answers 503 with
# `x-loyaly-config: misconfigured` on every gated request.
#
# ── Where the secret must be set in Dokploy ──────────────────────────────
# The "Environment Variables" tab. NOT "Build Arguments" and NOT "Build
# Secrets": Dokploy's own documentation is explicit that both of those are
# build-time only and are absent from the running container, so a secret placed
# there is indistinguishable, from inside the container, from never having been
# set at all. The boot log says which of the two happened.
#
# ── Two probe endpoints, deliberately separate ───────────────────────────
# /api/health LIVENESS — 200 whenever the process answers. Safe to probe
# unconditionally; can never remove a serving
# container from rotation.
# /api/ready READINESS — 503 while a required variable is missing. Meant
# for a DEPLOY gate, in Dokploy → Advanced → Swarm
# Settings, paired with Update Config
# `Order: start-first` + `FailureAction: rollback`
# so a misconfigured new task is rolled back while
# the previous good one keeps serving.
# 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