Files
ipx/web/Caddyfile
rays 1949f3e493 Compression across the board: directory answers, uploads, metrics, traces, the page (#253)
Measured 2026-10-16, uncompressed until now: the directory's answers (the feed list ipx reads,
603 KB of JSON, ~134 ms of a 159 ms Directory request), ipx's snapshot upload (~100 KB each half
hour), both /metrics (4.7 and 0.9 KB every 15 s) and every trace export to Tempo. Already
compressed: ipx-web's answers (zstd, #239), the home page and the directory's public pages
through Cloudflare (brotli, zstd).

- The directory compresses with brotli, zstd or gzip as asked, and unpacks a gzipped request.
- ipx sends its snapshot gzipped; the directory is deployed first.
- Both /metrics answer gzipped, as Prometheus asks.
- opentelemetry-otlp's gzip-http: OTEL_EXPORTER_OTLP_COMPRESSION=gzip compresses the export, set
  for all three in production's compose file; Caddy's exporter reads the same variable.
- web/build.mjs writes .br (quality 11) and .gz (level 9) beside each text file, and Caddy serves
  them in place (file_server precompressed): app.js 30 KB where zstd on the fly gave 37, app.css
  11 where 14. A .br or .gz asked for by name is refused, or /admin.js.br would be the admin
  page's script without the check that keeps it to admins; in a handle of its own, since a bare
  respond runs after the handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 14:02:39 +00:00

141 lines
4.5 KiB
Caddyfile

# ipx-web (#203): the frontend as static files, and everything else passed to ipx, on one origin,
# so the session cookie, live updates and Cloudflare Access work as they did when ipx served it.
# IPX_UPSTREAM where ipx is, host:port (ipx:8099)
# WEB_BIND where to listen (:8080)
# WEB_TRACING on: a span for each request to OpenTelemetry (OTEL_EXPORTER_OTLP_ENDPOINT,
# OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME), continuing the page's traceparent
# and passing it to ipx, so one trace runs browser, Caddy, ipx (#213). Off: none,
# and no export failing every few seconds where there is nothing to send to.
# TUNNEL_PEER the one address whose Cf-Access-* headers are passed on: where cloudflared's
# requests come from. Nobody else's are, so nobody else can claim to be signed in
# by Cloudflare. Unset passes none.
{
admin off
auto_https off
# Everything else Caddy logs, its errors among them, which carry the request too: the same
# filter as the access log's below.
log default {
output stderr
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
}
(tracing-on) {
tracing {
span ipx-web
}
}
(tracing-off) {
}
http://{$WEB_BIND::8080} {
import tracing-{$WEB_TRACING:off}
root * {$WEB_ROOT:/srv}
# One JSON line a request on stdout, for Loki and the dashboard's Page row (#210). Not the
# shared token a ?token= link carries, nor the request's headers, which hold the session
# cookie and Cloudflare Access's token.
log {
output stdout
format filter {
wrap json
fields {
request>uri query {
delete token
}
request>headers delete
}
}
}
# Compressed, zstd or gzip as the browser takes them: the feed list and a page of items went
# out at over 100 KB each (#239). Not the live updates, which a compressor would hold back
# until it had enough to send, nor /media, which is audio and video already compressed.
@compress not path /api/events /media/*
encode @compress zstd gzip
# The copies web/build.mjs compressed are served in place of their file, never asked for by
# name: /admin.js.br would be the admin page's script without the check below that keeps it to
# admins (#253).
# A handle of its own: a bare respond runs after the handles, and the last one serves any file.
@packed path *.br *.gz
handle @packed {
respond 404
}
# Before anything else, so no page or API call sees them: only ipx sets these.
@forged not remote_ip {$TUNNEL_PEER:255.255.255.255}
request_header @forged -Cf-Access-Authenticated-User-Email
request_header @forged -Cf-Access-Jwt-Assertion
@api path /api/* /media/*
handle @api {
# Live updates are a stream: flushed as they come, never buffered.
reverse_proxy {$IPX_UPSTREAM:ipx:8099} {
flush_interval -1
}
}
# Signing in with the shared token in the address (?token=): ipx sets the session cookie and
# sends the browser on to the page without it.
@tokened {
path / /admin
query token=*
}
handle @tokened {
rewrite * /api/signin?{query}
reverse_proxy {$IPX_UPSTREAM:ipx:8099}
}
# The app and the admin page go to whoever ipx says may have them: a browser not signed in is
# sent to /login, anyone but an admin asking for /admin to the app.
@page path / /index.html /admin /admin.html /admin.js
handle @page {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/page-check
}
rewrite / /index.html
rewrite /admin /admin.html
header Cache-Control no-cache
file_server {
precompressed br gzip
}
}
# The sign-in form, except for someone Cloudflare Access vouches for, who is sent to the app.
@login path /login /login.html
handle @login {
forward_auth {$IPX_UPSTREAM:ipx:8099} {
uri /api/login-check
}
rewrite * /login.html
header Cache-Control no-cache
file_server {
precompressed br gzip
}
}
# Scripts and the stylesheet are named by a hash of their contents (web/build.mjs), so a browser
# keeps them a year; the icons a day, under names that do not change.
@hashed path /app.js /admin.js /login.js /app.css /hls.js /boot.js
header @hashed Cache-Control "public, max-age=31536000, immutable"
# The same PNG as /favicon.png, for a browser that asks for /favicon.ico on its own.
header /favicon.ico Content-Type image/png
@icons path *.png *.svg *.ico *.woff2
header @icons Cache-Control "public, max-age=86400"
handle {
file_server {
precompressed br gzip
}
}
}