production is a static build behind nginx, not a process

The site's own build script says it outright: "The public site is not served by
Bun in production — it is a directory of files behind nginx. Output: dist/."
So production has no port, nothing to restart, and nothing that can crash at
three in the morning. It is built or stale, and served or not served.

Showing it as a pm2 process was showing something that does not exist, and its
Start button could only ever have failed. It now has Build, Serve and Stop, and
the two states are reported separately — a build can succeed while nothing
serves it, and a container can serve a directory built weeks ago. They fail
independently and are fixed differently.

The container exists because nginx could not see the files. NPM mounts only its
own /data and /etc/letsencrypt, and there is no other nginx on the box. The
alternatives were putting site files inside NPM's own volume, or editing NPM's
compose and restarting it — which takes every site on this machine down for a
moment. So: one nginx:alpine, dist mounted READ-ONLY, on NPM's own network with
NO published port. NPM reaches it by name, and nothing about the site is exposed
except through the proxy.

Recreated rather than restarted, for the same reason pm2 is: a container keeps
the mounts it was created with, so a root location changed here would be saved,
displayed, and still serving the old directory.

Domains follows. Production's forward target is a container NAME, not the docker
gateway that reaches a host process — and matching what already points at it
uses the full target rather than the port, since every other site behind this
NPM on port 80 would otherwise be listed as fofos's.

The compiler found the dead branches this left in startServer once production
could no longer reach it. Removed rather than left to read as though the case
were still possible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 19:36:29 +01:00
co-authored by Claude Opus 5
parent 752c06d24a
commit 27f93d6e14
6 changed files with 472 additions and 22 deletions
+48 -6
View File
@@ -73,6 +73,14 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
clientRef.current = client;
const [hosts, setHosts] = useState<Host[] | null>(null);
/*
Production is not a port on this machine.
It is a container on the same docker network as NPM, so the forward target is a NAME — `fofos-web:80`
— and the gateway address that reaches a host process would reach nothing. Read here rather than
passed down, because the shape of production's target is this block's business.
*/
const [staticTarget, setStaticTarget] = useState<{ host: string; port: number } | null>(null);
const [unavailable, setUnavailable] = useState(false);
const [domains, setDomains] = useState<Record<string, string>>({});
const [forwardHost, setForwardHost] = useState('');
@@ -89,6 +97,11 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
for (const host of found) counts.set(host.forward_host, (counts.get(host.forward_host) ?? 0) + 1);
const common = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
if (common) setForwardHost((typed) => typed || common);
const web = await clientRef.current
.get<{ container: { name: string } }>('/fofos-admin/_config/static')
.catch(() => null);
if (web?.container?.name) setStaticTarget({ host: web.container.name, port: 80 });
} catch {
// Not installed, not running, or not permitted. Reported as absent rather than as an error: this
// block is an integration with another plugin, and its absence is a fact rather than a fault.
@@ -108,14 +121,20 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
setFailed(false);
setOutput(null);
try {
// Production goes to the container by name; development to the host, at its port.
const target =
server.name === 'production' && staticTarget
? { host: staticTarget.host, port: staticTarget.port }
: { host: forwardHost.trim(), port: server.port! };
await clientRef.current.post('/proxy-manager/npm/nginx/proxy-hosts', {
...NEW_HOST,
domain_names: [domain],
forward_scheme: 'http',
forward_host: forwardHost.trim(),
forward_port: server.port,
forward_host: target.host,
forward_port: target.port,
});
setOutput(`${domain} → ${forwardHost.trim()}:${server.port}`);
setOutput(`${domain} → ${target.host}:${target.port}`);
setDomains((current) => ({ ...current, [server.name]: '' }));
await load();
} catch (failure) {
@@ -246,13 +265,33 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
const row = (server: Server) => {
const label = server.name === 'development' ? 'Development' : 'Production';
// Everything NPM already points at this server's port, whoever made it.
const mine = (hosts ?? []).filter((host) => host.forward_port === server.port);
/*
What already points at this server.
Matched on the FULL target rather than the port alone: production forwards to a container on port
80, and every other site behind this NPM that happens to use port 80 would otherwise be listed here
as though it belonged to fofos.
*/
const mine = (hosts ?? []).filter((host) =>
server.name === 'production' && staticTarget
? host.forward_host === staticTarget.host && host.forward_port === staticTarget.port
: host.forward_port === server.port && server.port !== null,
);
return (
<div key={server.name} className="flex flex-col gap-2 border-t border-border pt-3 first:border-0 first:pt-0">
<p className="text-xs text-muted-foreground">
{label}
{server.port ? <span className="font-mono"> · port {server.port}</span> : ' · no port set'}
<span className="font-mono">
{' · '}
{server.name === 'production'
? staticTarget
? `${staticTarget.host}:${staticTarget.port}`
: 'static, no server yet'
: server.port
? `port ${server.port}`
: 'no port set'}
</span>
</p>
{mine.map((host) => (
@@ -325,7 +364,10 @@ export const ProxiesBlock = ({ servers }: { servers: Server[] }) => {
};
// Nothing left to point anywhere: every server already has a domain.
const anythingToCreate = servers.some((server) => !(hosts ?? []).some((host) => host.forward_port === server.port));
// Development only: production's target is a container name, not the gateway address this field holds.
const anythingToCreate = servers.some(
(server) => server.name === 'development' && !(hosts ?? []).some((host) => host.forward_port === server.port),
);
return (
<Block icon={Globe} title="Domains" hint="Which addresses reach the site, through Nginx Proxy Manager.">
+20 -9
View File
@@ -8,9 +8,19 @@ import { Block } from './Block';
import { Console } from './Console';
import { useFlash } from './useFlash';
import { ProxiesBlock } from './ProxiesBlock';
import { StaticBlock } from './StaticBlock';
/**
* The two servers that serve the site, under pm2.
* How the site is served: a development process, and a production build.
*
* ── They are not two of the same thing, and this stopped pretending ──
*
* Development is `bun --hot` under pm2 — a process with a port, an uptime and a restart count.
* Production is a directory of files behind nginx, which the site's own build script says outright: it
* has no port and nothing to restart. It is built or stale, served or not served.
*
* So production gets Build and a container. Showing it as a process was showing something that does not
* exist, and its Start button could only ever have failed.
*
* A block inside Administration rather than a section of its own: it is the same KIND of thing as the
* root location and the databases — this machine, not the site's content — and a nav row for one card
@@ -88,7 +98,8 @@ export const ServersBlock = () => {
setServers(
await clientRef.current.put<Server[]>('/fofos-admin/_config/servers', {
development: Number(dev),
production: Number(prod),
// Still sent because the API validates that the two differ. Nothing listens on it.
production: Number(prod || 3010),
}),
);
flash();
@@ -121,9 +132,8 @@ export const ServersBlock = () => {
}
};
const dirty =
dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '') ||
prod !== String(servers?.find((s) => s.name === 'production')?.port ?? '');
// Only development has a port now. Production is a container on a docker network, publishing none.
const dirty = dev !== String(servers?.find((s) => s.name === 'development')?.port ?? '');
const row = (server: Server, value: string, onChange: (next: string) => void) => {
// pm2 saying `online` is not the same as the site answering — `bun --hot` stays alive after a
@@ -214,16 +224,17 @@ export const ServersBlock = () => {
>
{!servers ? null : (
<>
{servers.map((server) =>
server.name === 'development' ? row(server, dev, setDev) : row(server, prod, setProd),
)}
{servers.filter((server) => server.name === 'development').map((server) => row(server, dev, setDev))}
{/* Production, which is a build rather than a process — see StaticBlock. */}
<StaticBlock />
{error && <p className="text-xs text-destructive">{error}</p>}
<Console output={output} failed={failed} />
{dirty && (
<div className="flex justify-end border-t border-border pt-3">
<Button size="sm" onClick={() => void savePorts()} disabled={busy !== null || !dev || !prod}>
<Button size="sm" onClick={() => void savePorts()} disabled={busy !== null || !dev}>
{busy === 'ports' ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
Save ports
</Button>
+142
View File
@@ -0,0 +1,142 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { CircleDot, Hammer, Loader2, Play, Square } from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Button } from '@/components/ui/button';
import { readableError } from './errors';
import { Console } from './Console';
/**
* Production: the built site, and the nginx serving it.
*
* ── Two states, not one ──
*
* A build can succeed while nothing serves it, and a container can serve a directory built weeks ago.
* They fail independently and they are fixed differently, so they are reported separately rather than
* collapsed into "production is up" — which would be true in one of those cases and misleading in the
* other.
*
* ── Build takes minutes ──
*
* It renders every route through the real app and copies every referenced file. Said in the button,
* because a spinner sitting for three minutes with no explanation is indistinguishable from a hang.
*/
type Static = {
path: string;
built: boolean;
entries: number | null;
builtAt: string | null;
container: { name: string; state: string | null; forward: string };
};
const when = (iso: string) =>
new Date(iso).toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
export const StaticBlock = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [status, setStatus] = useState<Static | null>(null);
const [busy, setBusy] = useState<'build' | 'start' | 'stop' | null>(null);
const [output, setOutput] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
const load = useCallback(async () => {
try {
setStatus(await clientRef.current.get<Static>('/fofos-admin/_config/static'));
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const act = async (what: 'build' | 'start' | 'stop') => {
setBusy(what);
setFailed(false);
setOutput(what === 'build' ? 'Rendering every page. This takes a few minutes.' : null);
try {
const path = what === 'build' ? '/fofos-admin/_config/static/build' : `/fofos-admin/_config/static/${what}`;
const result = await clientRef.current.post<{ ok: boolean; output: string; status: Static }>(path, {});
setStatus(result.status);
setOutput(result.output);
setFailed(!result.ok);
} catch (failure) {
setOutput(readableError(failure));
setFailed(true);
void load();
} finally {
setBusy(null);
}
};
if (!status) return null;
const serving = status.container.state === 'running';
return (
<div className="flex flex-col gap-2 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-3">
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1.5 text-xs">
<CircleDot
className={`size-3 ${serving ? 'text-success' : status.container.state ? 'text-destructive' : 'text-muted-foreground/40'}`}
/>
Production
<span className="text-muted-foreground/70">
{status.built ? `${status.entries} files` : 'not built'}
{status.builtAt ? ` · built ${when(status.builtAt)}` : ''}
{' · '}
{status.container.state ?? 'no server'}
</span>
</p>
{/* The forward target, because it is what a domain has to point at and it is not a port. */}
<p className="font-mono text-xs text-muted-foreground/60">
{status.container.name} · {serving ? status.container.forward : 'static files, no port'}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
className="w-24 justify-center"
onClick={() => void act('build')}
disabled={busy !== null}
>
{busy === 'build' ? <Loader2 className="size-3.5 animate-spin" /> : <Hammer className="size-3.5" />}
{busy === 'build' ? 'Building' : 'Build'}
</Button>
<Button
variant="outline"
size="sm"
className="w-24 justify-center"
onClick={() => void act('start')}
// Nothing to serve until something is built, and starting on an empty directory produces a
// container that returns 404 for everything — which reads as a broken site, not a missing build.
disabled={busy !== null || !status.built}
title={status.built ? undefined : 'Build it first'}
>
{busy === 'start' ? <Loader2 className="size-3.5 animate-spin" /> : <Play className="size-3.5" />}
{serving ? 'Restart' : 'Serve'}
</Button>
<Button
variant="outline"
size="sm"
className="w-24 justify-center"
onClick={() => void act('stop')}
disabled={busy !== null || !status.container.state}
>
<Square className="size-3.5" />
Stop
</Button>
</div>
</div>
<Console output={output} failed={failed} />
</div>
);
};