Skip to content

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.

  • Path on the host and in the web container: /opt/ProjectStart/web/web.sock, set by PS_WEB_SOCKET in compose.yaml.
  • The web makes it each time it starts, with mode 0660. The folder /opt/ProjectStart/web is project:caddy with setgid (Install), so the socket gets group caddy and Caddy can connect.

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/*.caddy
  • header_up X-Real-IP tells the web the visitor’s address (Visitor address).
  • The import line 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/caddy or /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/caddy and imports the route folder.
  • Serve the admin site over HTTPS only. The session cookies are marked Secure.
  • Leave the Host header 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.

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_DOMAIN on 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’s daemon.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_keys is 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.

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-Policy and Strict-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.

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.

Facts from web-limits 4fa34de, not merged yet:

  • The web lifts adapter-node’s BODY_SIZE_LIMIT itself (src/web/server.ts); compose.yaml needs 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 to MAX_UPLOAD_BYTES, 500 * 1024 * 1024 bytes in src/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 /login instead). 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/upload starts it (no body, its size in a header, at most MAX_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_BYTES on the admin site.

Not verified on dev.

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-IP when Node’s net.isIP accepts it.
  • When the header is missing or isn’t an address, the web logs web.real_ip_invalid and counts the visitor under the shared address unknown. Without the header_up line every visitor shares that one address, and so one throttle: one visitor’s failed sign-ins lock out everyone.
  • Caddy’s header_up replaces any X-Real-IP a 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.