nginx gets a configuration instead of the stock one

`nginx:alpine` ships a config that serves the directory and does nothing else: no compression, and no
cache headers at all. Every asset on the live site carried a TTL of zero, so a returning visitor
re-downloaded all of it — 1.1 MB by Lighthouse's count.

Written out on every start rather than kept as a file somebody edits, so what nginx does is decided in
this file and cannot drift from it.

Three lifetimes, because there are three kinds of file. `derived/` renditions are rebuilt IN PLACE —
av-club replaces the contents of `1280.webp` rather than writing a new name — so they cannot be
`immutable` however much they look like they could be, or a re-crop stays invisible for a year to
everyone who saw the old one. Bundled assets change only when a build runs, and a build changes the
HTML that names them: a week. HTML revalidates every time, because anything longer makes a publish
invisible to whoever already visited, which is the one failure that makes the whole panel look broken.

`^~` on the prefix locations. A regex location beats a prefix location in nginx, so the extension rule
would otherwise capture /derived/…/1280.webp and give it the wrong lifetime.

Validated with `nginx -t` in the real image rather than by reading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-29 22:07:50 +01:00
co-authored by Claude Opus 5
parent 1d9a621bd2
commit a3e8f3fbc4
+73 -1
View File
@@ -1,4 +1,4 @@
import { stat, readdir } from 'node:fs/promises';
import { stat, readdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { db } from 'officerdb/db';
import { fofosAdminSettings } from '../db/schema';
@@ -181,6 +181,73 @@ export async function buildSite(secrets: {
* the shared network by name, and a port on the host would be the site available unauthenticated beside
* the proxy that exists to gate it.
*/
/**
* The nginx configuration the container runs.
*
* Written out on every start rather than kept as a file somebody edits, so what nginx does is decided
* here and cannot drift. The stock `nginx:alpine` config serves the directory and nothing else: no
* compression, and no cache headers at all, so every asset carried a TTL of zero and a returning
* visitor re-downloaded the whole site.
*
* ── Three lifetimes, because there are three kinds of file ──
*
* `derived/` renditions are rebuilt IN PLACE — av-club replaces the contents of `1280.webp` rather
* than writing a new name. So they cannot be `immutable`, however much they look like they could be:
* a re-crop would be invisible to everyone who had seen the old one, for as long as the header said.
* Thirty days is the compromise, and it is the reason this is spelled out rather than copied from the
* usual recipe.
*
* Bundled assets — the stylesheet, the scripts, the fonts — change only when a build runs, and a build
* is also the only thing that changes the HTML that references them. A week, revalidated after.
*
* HTML is `no-cache`: revalidate every time. Not `no-store` — the file is still cached, it is just
* never used without asking. Anything longer and a publish is invisible to whoever already visited,
* which is the one failure that makes the whole panel look broken.
*
* ── Why `^~` on the prefixes ──
*
* A regex location beats a prefix location in nginx, so the extension rule below would otherwise
* capture `/derived/…/1280.webp` and hand it the wrong lifetime. `^~` stops the regex from being
* considered at all. This is the kind of thing that is correct or silently wrong with no middle.
*/
const NGINX_CONF = `server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Text compresses; the images and video here are already compressed formats and would only burn CPU.
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/css text/plain application/javascript application/json image/svg+xml;
# Rebuilt in place by av-club, so a long life would pin a stale crop. See the note above.
location ^~ /derived/ {
expires 30d;
add_header Cache-Control "public";
}
location ^~ /media/ {
expires 30d;
add_header Cache-Control "public";
}
location ~* \\.(css|js|woff2|webp|png|jpg|jpeg|gif|svg|ico|mp4)$ {
expires 7d;
add_header Cache-Control "public";
}
location / {
# The build writes about/index.html, so a directory match is what serves /about.
try_files $uri $uri/ =404;
add_header Cache-Control "no-cache";
}
}
`;
export async function startStatic(): Promise<{ ok: boolean; output: string }> {
const row = await readRow();
if (!row?.rootPath) throw new Error('No root location is set.');
@@ -188,6 +255,10 @@ export async function startStatic(): Promise<{ ok: boolean; output: string }> {
const dist = distPath(row.rootPath);
if (!(await stat(dist).catch(() => null))) throw new Error('There is no dist directory yet — build first.');
// Beside the site rather than inside dist/, which the build empties and replaces.
const conf = join(row.rootPath, 'nginx.conf');
await writeFile(conf, NGINX_CONF);
// Expected to fail the first time; the usual case is that it was never there.
await run([DOCKER, 'rm', '-f', CONTAINER], 30_000);
@@ -199,6 +270,7 @@ export async function startStatic(): Promise<{ ok: boolean; output: string }> {
'--network', NETWORK,
// Read-only: nginx serves this and has no business writing to somebody's build output.
'-v', `${dist}:/usr/share/nginx/html:ro`,
'-v', `${conf}:/etc/nginx/conf.d/default.conf:ro`,
IMAGE,
],
120_000,