Portal app-shell as a busybox target, git without perl, quieter first start
Docs / docs (push) Successful in 28s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m44s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m34s
pre-commit / pre-commit (push) Failing after 2m29s
Test Backend / test-backend (3.12) (push) Successful in 4m32s
Test Backend / test-backend (3.14) (push) Successful in 4m15s
Compose Smoke Test / test-compose (push) Successful in 37s
Playwright Tests / merge-reports (push) Successful in 1m33s

The portal's copy of the dashboard is the `appshell` target of
backend/Dockerfile (18 MB instead of the 252 MB nginx image); the nginx
frontend Dockerfile, its confs and csp.check.ts are gone, since the app and
the hub both hash the CSP from the index.html they serve.

The runtime image copies the 4 MB git binary alone: the store's init, add,
commit, gc and rev-parse are builtins, and a build-time round trip fails the
build if that ever changes. About 100 MB of perl and helpers less.

The bundled Redis's overcommit warning and FastMCP's lifespan warning no
longer greet every `docker logs`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 12:20:13 +02:00
co-authored by Claude Opus 5.5
parent 48aac3855b
commit 88934f0264
10 changed files with 46 additions and 174 deletions
-35
View File
@@ -1,35 +0,0 @@
# Stage 0, "build-stage", based on Bun, to build and compile the frontend
FROM oven/bun:1 AS build-stage
WORKDIR /app
COPY package.json bun.lock /app/
COPY frontend/package.json /app/frontend/
WORKDIR /app/frontend
RUN bun install
COPY ./frontend /app/frontend
ARG VITE_API_URL
# Set only for the copy a portal serves; see vite.config.ts.
ARG VITE_BASE
RUN bun run build
# Stage 1, based on Nginx, to have only the compiled app, ready for production with Nginx
FROM nginx:1
COPY --from=build-stage /app/frontend/dist/ /usr/share/nginx/html
COPY ./frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY ./frontend/nginx-security-headers.conf /etc/nginx/security-headers.conf
# The CSP's connect-src names the API when the bundle calls it on another
# origin (https://api.x and wss://api.x); empty, nginx proxies it on this one.
ARG VITE_API_URL
RUN api="${VITE_API_URL%/}"; \
sed -i "s|__API_ORIGINS__|${api:+ $api $(echo "$api" | sed 's/^http/ws/')}|" \
/etc/nginx/security-headers.conf
COPY ./frontend/nginx-backend-not-found.conf /etc/nginx/extra-conf.d/backend-not-found.conf
-9
View File
@@ -1,9 +0,0 @@
location /api {
return 404;
}
location /docs {
return 404;
}
location /redoc {
return 404;
}
-15
View File
@@ -1,15 +0,0 @@
# Security headers. Included at server level and again in every location with
# an add_header of its own, since nginx drops the inherited ones there.
#
# script-src: the bundle, plus the theme script inline in index.html by its
# hash. Edit that script and this hash must change with it (sha256 of the text
# between the tags, base64), or the app paints light-then-dark again;
# `bun run check` (src/csp.check.ts) fails with the new value.
# style-src 'unsafe-inline': Monaco, Radix's scroll lock and the toasts insert
# <style> elements at runtime, which no hash can name.
# img/frame-src take any http(s): a camera stream or website widget points
# wherever its dashboard says. connect-src is this origin plus the API's when
# the bundle calls one elsewhere; the Dockerfile fills that in.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-FpmE/jXgh7LaQmoMHvqlYVa3hl7+cMNkDN2fSejs9So='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; media-src 'self' blob:; font-src 'self'; connect-src 'self'__API_ORIGINS__; worker-src 'self'; frame-src http: https:; frame-ancestors 'self'; object-src 'none'; base-uri 'self'; form-action 'self'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
-72
View File
@@ -1,72 +0,0 @@
# The live socket carries its credential as `?token=`, and so does a password
# reset link, which then rides along as the Referer of everything that page
# loads. nginx's own format logs both as sent; this one masks the value.
map $request $request_masked {
"~^(?<request_head>.*?[?&][\w-]*token=)[^&\s]*(?<request_tail>.*)$" "${request_head}***${request_tail}";
default $request;
}
map $http_referer $referer_masked {
"~^(?<referer_head>.*?[?&][\w-]*token=)[^&\s]*(?<referer_tail>.*)$" "${referer_head}***${referer_tail}";
default $http_referer;
}
log_format masked '$remote_addr - $remote_user [$time_local] "$request_masked" '
'$status $body_bytes_sent "$referer_masked" '
'"$http_user_agent" "$http_x_forwarded_for"';
server {
listen 80;
access_log /var/log/nginx/access.log masked;
# nginx's mime.types predates the manifest, so without this it goes out as
# application/octet-stream.
types {
application/manifest+json webmanifest;
}
include /etc/nginx/mime.types;
# The bundle is ~770 kB of JavaScript and ~85 kB of CSS, and nginx's base
# image ships gzip commented out — so every first load shipped all of it
# uncompressed. Roughly a third of the bytes with this on.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_types
application/javascript
application/json
application/manifest+json
application/wasm
image/svg+xml
text/css
text/plain;
include /etc/nginx/security-headers.conf;
# The fallback is the last argument, so it is an internal redirect to the
# exact location below and `/` and every SPA route get its no-cache too; as
# a middle argument it was served from here, without one.
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri /index.html;
}
# Vite puts a content hash in every asset's name, so one can never change
# under its URL. Without this each of the ~50 of them cost a 304 round trip
# on every reload.
location /assets/ {
root /usr/share/nginx/html;
include /etc/nginx/security-headers.conf;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# The one file that must not be cached: it is what names the current
# assets, so a stale copy pins the browser to the previous build.
location = /index.html {
root /usr/share/nginx/html;
include /etc/nginx/security-headers.conf;
add_header Cache-Control "no-cache";
}
include /etc/nginx/extra-conf.d/*.conf;
}
-33
View File
@@ -1,33 +0,0 @@
/**
* The CSP's hash for the theme script, checked against the script.
*
* ponytail: a script rather than a suite, as with the other `.check.ts` files:
*
* cd frontend && bun run src/csp.check.ts
*
* `index.html` runs one inline script, the theme class before first paint, and
* `nginx-security-headers.conf` admits it by its sha256. Edit one without the
* other and the browser blocks the script, so the app paints light-then-dark
* again, with nothing failing anywhere but the console.
*/
import assert from "node:assert/strict"
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
const read = (path: string) =>
readFileSync(new URL(path, import.meta.url), "utf8")
const inline = [
...read("../index.html").matchAll(/<script>([\s\S]*?)<\/script>/g),
]
assert.equal(inline.length, 1, "index.html has one inline script")
const hash = createHash("sha256").update(inline[0][1]).digest("base64")
const csp = read("../nginx-security-headers.conf")
assert.ok(
csp.includes(`'sha256-${hash}'`),
`the theme script's hash is not in nginx-security-headers.conf; set it to 'sha256-${hash}'`,
)
console.log("csp: ok")
+1 -1
View File
@@ -2,7 +2,7 @@
* Zod without its `eval` probe.
*
* Zod compiles a faster object parser with `new Function` where it can, and
* finds out by trying once. Under the CSP (`frontend/nginx-security-headers.conf`)
* finds out by trying once. Under the CSP (`backend/fluksio/spa.py`)
* that try is reported as a violation on every page, although Zod catches it
* and falls back. `jitless` skips the probe and goes straight to the fallback.
* Imported first in `main.tsx`, since a schema built before it would probe.