Fail the image build when the Nexus token is missing

`docker compose build` without NEXUS_TOKEN produced a Nexus 401 forty
seconds in, worded as a credentials problem when it was really an unset
variable. The mount's `required=true` asserts the secret was *declared*,
not that it holds anything, so an empty one sailed past it and npm sent
an empty `_authToken`.

The install moves to tools/docker-install-deps.sh, which checks the
secret before touching the network and fails in under a second with the
three ways to supply it. It is a file rather than inline shell because
BuildKit echoes an inline command twice on failure and buries the
message.

Also ignore .env. It carries NEXUS_TOKEN, compose reads it at build
time, and it was not gitignored — .env.example stays tracked. Nothing
had been committed: the only prior commit predates all of this.

Verified: empty token fails in 0.2s with the message; .env and the
environment variable both build; the image carries no trace of the
token and serves the full page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
pigui
2026-08-30 21:56:11 +02:00
parent 3f930bb46a
commit a226cbddbd
6 changed files with 100 additions and 13 deletions

View File

@@ -11,6 +11,9 @@ tmp
.claude .claude
docs docs
landing-e2e landing-e2e
# tools/ IS needed: the build stage copies docker-install-deps.sh from it.
# The mock API servers in there are dev-only but too small to be worth excluding.
**/*.spec.ts **/*.spec.ts
test-output test-output
.env .env

View File

@@ -1,10 +1,19 @@
# Base URL of the Pireon API, without a trailing slash. Read on the server # Copy to `.env`. Compose reads it on its own — for the runtime settings below
# only: the browser never talks to the API directly. # and for the build-time token. `.env` is gitignored; this file is not.
# Base URL of the Pireon API, without a trailing slash. Read on the server only:
# the browser never talks to the API directly.
PIREON_API_URL=https://landing-api.pireon.dev/api PIREON_API_URL=https://landing-api.pireon.dev/api
# Host port the container is published on. # Host port the container is published on. Change it if something else already
# holds 3000 on the machine — the container always listens on 3000 internally.
LANDING_PORT=3000 LANDING_PORT=3000
# Only needed to *build* the image: `@pi/ui` comes from the private Nexus. # Needed only to *build* the image: `@pi/ui` comes from the private Nexus.
# Not read at runtime, and never baked into a layer — see Dockerfile. # Locally this is the value already sitting in ~/.npmrc:
#
# grep nexus.pireon.dev ~/.npmrc
#
# Never baked into a layer — it is mounted as a BuildKit secret. An empty value
# fails the build immediately with an explanation rather than a Nexus 401.
NEXUS_TOKEN= NEXUS_TOKEN=

10
.gitignore vendored
View File

@@ -46,3 +46,13 @@ Thumbs.db
.nx/polygraph .nx/polygraph
.angular .angular
# Secrets. `.env` holds NEXUS_TOKEN, which compose reads at build time, so it
# must never be committed — `.env.example` is the one that is.
.env
.env.*
!.env.example
# A per-machine npmrc would carry a Nexus token; the committed one at the repo
# root deliberately holds none.
.npmrc.local

View File

@@ -19,14 +19,12 @@ COPY package.json package-lock.json .npmrc ./
# `@pi/ui` comes from the private Nexus, so the install needs a token — passed # `@pi/ui` comes from the private Nexus, so the install needs a token — passed
# as a BuildKit secret rather than a build ARG, which would be readable forever # as a BuildKit secret rather than a build ARG, which would be readable forever
# in the image history. The npmrc it lands in is written and removed inside one # in the image history. The script checks the secret actually holds something
# layer, so it leaves no trace even in this discarded stage. # before spending four minutes finding out from Nexus.
COPY tools/docker-install-deps.sh ./tools/
RUN --mount=type=secret,id=nexus_token,required=true \ RUN --mount=type=secret,id=nexus_token,required=true \
sh -eu -c '\ sh tools/docker-install-deps.sh
printf "//nexus.pireon.dev/repository/npm-releases/:_authToken=%s\n" \
"$(cat /run/secrets/nexus_token)" > /tmp/npmrc; \
NPM_CONFIG_USERCONFIG=/tmp/npmrc npm ci --no-audit --no-fund; \
rm -f /tmp/npmrc'
COPY nx.json tsconfig.base.json eslint.config.mjs ./ COPY nx.json tsconfig.base.json eslint.config.mjs ./
COPY landing ./landing COPY landing ./landing

View File

@@ -18,10 +18,25 @@ npm run e2e # Playwright end-to-end tests
## Deploying ## Deploying
```sh ```sh
NEXUS_TOKEN=… docker compose build cp .env.example .env # then fill in NEXUS_TOKEN
docker compose build
docker compose up -d docker compose up -d
``` ```
Compose reads `.env` on its own, for the build token as well as the runtime
settings. `.env` is gitignored; `.env.example` is the one that is committed. If
you would rather not keep the token on disk:
```sh
NEXUS_TOKEN=… docker compose build
```
An empty or missing token fails the build in under a second with a message
saying so. That check is there because the mount's own `required=true` only
asserts the secret was *declared* — an empty one passes it and then surfaces a
minute later as a Nexus 401 that reads like a credentials problem when it is
really a forgotten variable.
Two stages. The build one runs Nx and the Angular compiler over the whole Two stages. The build one runs Nx and the Angular compiler over the whole
workspace and is thrown away; only `dist/landing/analog` crosses into the workspace and is thrown away; only `dist/landing/analog` crosses into the
runtime image, because Nitro vendors its own dependencies into that output — runtime image, because Nitro vendors its own dependencies into that output —

52
tools/docker-install-deps.sh Executable file
View File

@@ -0,0 +1,52 @@
#!/bin/sh
# Installs the workspace's dependencies inside the image.
#
# Split out of the Dockerfile so a failure prints this file's own message
# instead of BuildKit echoing a twenty-line inline shell twice.
set -eu
TOKEN_FILE=/run/secrets/nexus_token
NEXUS_PATH=//nexus.pireon.dev/repository/npm-releases/
# `required=true` on the mount only asserts the secret was declared, not that it
# holds anything. An empty one sails past it and surfaces four hundred packages
# later as a Nexus 401 that reads like a credentials problem.
if [ ! -s "$TOKEN_FILE" ]; then
cat >&2 <<'MSG'
ERROR: the nexus_token build secret is empty.
@pi/ui is published to the private Nexus, so the install needs a token.
compose: NEXUS_TOKEN=... docker compose build
or put NEXUS_TOKEN in .env, which compose reads on its own
docker: docker build --secret id=nexus_token,env=NEXUS_TOKEN .
Locally the value is the one already in ~/.npmrc:
grep nexus.pireon.dev ~/.npmrc
MSG
exit 1
fi
# Written and removed inside a single layer, so it leaves no trace even in this
# stage — which is discarded anyway.
NPMRC=/tmp/npmrc
printf '%s:_authToken=%s\n' "$NEXUS_PATH" "$(cat "$TOKEN_FILE")" > "$NPMRC"
if NPM_CONFIG_USERCONFIG="$NPMRC" npm ci --no-audit --no-fund; then
rm -f "$NPMRC"
else
status=$?
rm -f "$NPMRC"
if [ "$status" -ne 0 ]; then
echo "" >&2
echo "npm ci failed. If the error above is a 401 from nexus.pireon.dev," >&2
echo "the token was present but is not valid for npm-releases." >&2
echo "" >&2
fi
exit "$status"
fi