Compare commits

..

2 Commits

Author SHA1 Message Date
pigui
68ad014df0 Stop publishing a host port
`docker compose up` failed with "port is already allocated": 3000 was
taken by an unrelated project on the same machine. Publishing it was
never needed — in production the only thing that has to reach this
service is the reverse proxy, which does so over the compose network as
`landing:3000`.

`expose` replaces `ports`, and a commented `ports` block stays for the
cases that do want a host binding. LANDING_PORT is kept but re-labelled
as only mattering then.

Verified: the container binds nothing on the host, answers 200 with the
full page from another container on the network, and stays healthy —
its health check was always internal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 21:59:42 +02:00
pigui
a226cbddbd 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>
2026-08-30 21:56:11 +02:00
7 changed files with 124 additions and 18 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,21 @@
# 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. # Only used if you uncomment `ports` in docker-compose.yml. By default the
# container is not published on the host at all: the reverse proxy reaches it
# over the compose network as `landing:3000`, and publishing a host port only
# invites a collision with whatever else is on that number.
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 —
@@ -56,15 +71,20 @@ API were in maintenance. `core/api.ts` gives each call an 8-second budget, which
is what stands between a slow upstream and a hung render. is what stands between a slow upstream and a hung render.
The container runs with a read-only filesystem and `no-new-privileges`, with a The container runs with a read-only filesystem and `no-new-privileges`, with a
tmpfs at `/tmp` for Nitro's scratch space. Put a reverse proxy in front for TLS tmpfs at `/tmp` for Nitro's scratch space. The container publishes no host port: the reverse proxy reaches it over the
and let it pass response headers through untouched: the app answers 503 with compose network as `landing:3000`, and binding a host port only invites a
collision with whatever else is on that number. `docker-compose.yml` carries a
commented `ports` block for when you do want one.
Put that proxy in front for TLS and let it pass response headers through
untouched: the app answers 503 with
`Retry-After` during maintenance and 404 for addresses it does not serve, and `Retry-After` during maintenance and 404 for addresses it does not serve, and
both matter to crawlers and CDNs. both matter to crawlers and CDNs.
| Variable | Where | Default | | Variable | Where | Default |
| --- | --- | --- | | --- | --- | --- |
| `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` | | `PIREON_API_URL` | runtime | `https://landing-api.pireon.dev/api` |
| `LANDING_PORT` | runtime, host side | `3000` | | `LANDING_PORT` | only if you publish a host port | `3000` |
| `NEXUS_TOKEN` | **build only** | — | | `NEXUS_TOKEN` | **build only** | — |
Copy `.env.example` to `.env`; compose reads it. Copy `.env.example` to `.env`; compose reads it.

View File

@@ -24,8 +24,20 @@ services:
- nexus_token - nexus_token
image: pireon/landing:latest image: pireon/landing:latest
restart: unless-stopped restart: unless-stopped
ports: # No `ports`. Publishing on the host collides with whatever else is using
- '${LANDING_PORT:-3000}:3000' # that number on the machine, and in production the only thing that needs
# to reach this is the reverse proxy — which does so over the compose
# network, by service name, on the port below.
#
# proxy_pass http://landing:3000;
#
# To reach it from the host anyway (a quick check, or a deployment with no
# proxy in front), uncomment this and set LANDING_PORT to a free port:
#
# ports:
# - '${LANDING_PORT:-3000}:3000'
expose:
- '3000'
environment: environment:
# Where the public API lives. The only setting the app actually reads. # Where the public API lives. The only setting the app actually reads.
PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api} PIREON_API_URL: ${PIREON_API_URL:-https://landing-api.pireon.dev/api}

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