Reverse proxy
The host’s Caddy serves the admin site and the project sites. The web listens on no TCP port, only on a Unix socket.
The web’s socket
Section titled “The web’s socket”- Path on the host and in the web container:
/opt/ProjectStart/web/web.sock, set byPS_WEB_SOCKETincompose.yaml. - The web makes it each time it starts, with mode 0660. The folder
/opt/ProjectStart/webis project:caddy with setgid (Install), so the socket gets groupcaddyand Caddy can connect.
Caddyfile
Section titled “Caddyfile”Add this to /etc/caddy/Caddyfile, with the admin site’s domain (PS_APP_DOMAIN in .env):
<PS_APP_DOMAIN> { reverse_proxy unix//opt/ProjectStart/web/web.sock { header_up X-Real-IP {remote_host} }}
import /opt/ProjectStart/caddy/*.caddyheader_up X-Real-IPtells the web the visitor’s address (Visitor address).- The
importline must stand on its own line exactly as written. The controller refuses to change routes when it is missing. Each project gets a file there, which proxies its domain to port 3000 of its container. - Keep Caddy’s admin API at its default address,
http://127.0.0.1:2019. The Caddy helper reads the live routes there. - Keep every file the Caddyfile names under
/etc/caddyor/opt/ProjectStart/caddy. The controller validates the whole Caddyfile inside its container, which sees only those two folders. On dev, the host Caddyfile names only a root certificate under/etc/caddyand imports the route folder. - Serve the admin site over HTTPS only. The session cookies are marked
Secure. - Leave the
Hostheader as Caddy passes it. The app takes its own address from it.
Not verified on dev. Dev’s Caddy proxies to a TCP port, not to this socket.
Certificate pins of reverse hosts
Section titled “Certificate pins of reverse hosts”A remote host given a reverse connection pins the admin site’s TLS keys (src/web/site-pin.ts):
- When its install command is downloaded, the web connects to
PS_APP_DOMAINon port 443. It writes the SHA-256 of the public key of each certificate in the chain served there, the site’s own and its issuers’ up to the root, into the daemon’sdaemon.json(pin). - The daemon refuses a connection whose chain holds none of those keys, before it sends its token. It logs
connection refused: the site's certificate holds none of the keys pinned at install. - Caddy makes a new key for each new certificate unless the global option
reuse_private_keysis set. The issuer’s and the root’s keys stay the same across renewals, so a renewed certificate from the same CA still connects. - A site moved to a CA with another root, or to a TLS inspection proxy, is refused. Make a new install command for each reverse host then, and run it on the host again.
- When the web can’t reach its own domain over TLS as the command is made, it logs
remote.install_no_pins, and that host’s daemon pins nothing, as installs before pinning did.
Security headers
Section titled “Security headers”The web sets the admin site’s security headers itself, so the Caddyfile adds none:
- Pages carry the app’s content security policy, with a per-response nonce for its inline scripts.
- Every response carries
X-Content-Type-Options,Referrer-PolicyandStrict-Transport-Security. - The chat’s files and shown pages carry their own, stricter policy.
Don’t add a Content-Security-Policy header in Caddy. A browser enforces every policy it gets, so a second one
would block what the app’s own policy allows, such as its inline scripts.
Event streams
Section titled “Event streams”Pages get live updates as Server-Sent Events (text/event-stream). The app sends a ping event every 15 seconds on
each stream. Caddy passes an event stream on as it comes, with no setting. Don’t add buffering or a response timeout
to this site. Not verified on dev. Event streams through the Unix socket have not run on dev.
Uploads
Section titled “Uploads”Facts from web-limits 4fa34de, not merged yet:
- The web lifts adapter-node’s
BODY_SIZE_LIMITitself (src/web/server.ts);compose.yamlneeds no setting. Its handle hook (app/src/hooks.server.ts) checks each request’s declared size before anything reads the body (src/web/limits.ts). - Signed-in app routes (route ids under
/(app)) take bodies up toMAX_UPLOAD_BYTES,500 * 1024 * 1024bytes insrc/shared/agentLimits.ts. - Public routes (sign-in, setup) keep 512 KiB, adapter-node’s default.
- A body over the route’s limit gets 413. A body without a numeric
Content-Length, such as a chunked one, gets 411. Both answers carry text in the visitor’s language. - A signed-out request to an app route gets 401 (a page load is sent to
/logininstead). Its body is never read: the hook looks only at the headers. - The chat sends each file in chunks: a POST to
/projects/<id>/agents/uploadstarts it (no body, its size in a header, at mostMAX_UPLOAD_BYTES), then a PUT to/projects/<id>/agents/upload/<upload id>carries each chunk of at most 256 KiB. A chat message carries up to 20 files. - Caddy sets no request body limit unless the Caddyfile asks for one. Don’t set one below
MAX_UPLOAD_BYTESon the admin site.
Not verified on dev.
Visitor address
Section titled “Visitor address”Sign-in throttling counts failed sign-ins per visitor address. The web is reachable only through the host’s Caddy:
in production it listens on its Unix socket and nothing else. So the visitor’s address must come from Caddy, in
X-Real-IP.
Facts from web-limits 4fa34de, not merged yet (clientAddress in src/web/auth.ts):
- The web always takes the address from
X-Real-IPwhen Node’snet.isIPaccepts it. - When the header is missing or isn’t an address, the web logs
web.real_ip_invalidand counts the visitor under the shared addressunknown. Without theheader_upline every visitor shares that one address, and so one throttle: one visitor’s failed sign-ins lock out everyone. - Caddy’s
header_upreplaces anyX-Real-IPa visitor sent.
The header_up X-Real-IP line is required. On Caddy 2.6.2, the host’s version, it must use {remote_host}: 2.6.2 has
no {client_ip} placeholder. On the dev host’s Caddy 2.6.2, {client_ip} came out as the literal text
{client_ip}, which the web rejects as not an address, and {remote_host} as the client’s address. With no
trusted_proxies, both name the same address on newer Caddy.
Not verified on dev.