M1+M2+M3 Tech Research — Whilly Distributed

Research conducted 2026-05-01. Sources cited inline. All code snippets target Python 3.10+ / Caddy v2.x / Tailscale 1.52+ / HTMX 2.x / asyncpg 0.29+. This is a worker-facing knowledge artifact: copy-paste-ready snippets + the gotchas that bite in production.


1. Caddy v2 reverse-proxy + ACME

auth lives at FastAPI — Caddy only does TLS termination, HSTS, security headers, and reverse-proxy. Bearer tokens flow through unchanged.

# Caddyfile — saved at /etc/caddy/Caddyfile inside the container
# Replace <host> at deploy time. Examples:
#   203-0-113-42.sslip.io     (sslip.io / nip.io style, IPv4 dashes)
#   whilly.example.com        (real domain)
{
    # Global options
    email ops@example.com         # used by Let's Encrypt for expiry warnings
    # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory   # uncomment for staging
}

{$WHILLY_PUBLIC_HOST} {
    # ACME HTTP-01 happens automatically on :80 (Caddy redirects HTTP→HTTPS by default)

    encode gzip zstd

    # Security headers — Caddy does NOT set these by default
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options    "nosniff"
        X-Frame-Options           "DENY"
        Referrer-Policy           "strict-origin-when-cross-origin"
        # Hide upstream identification
        -Server
    }

    # SSE endpoints need long-lived streaming — disable buffering and bump timeouts.
    @sse path /events/stream /workers/*/stream
    reverse_proxy @sse whilly-control-plane:8000 {
        flush_interval -1
        transport http {
            read_timeout  24h
            write_timeout 24h
        }
    }

    # Everything else (incl. /metrics, /api/*, /dashboard, /htmx fragments)
    reverse_proxy whilly-control-plane:8000 {
        header_up X-Forwarded-Proto {scheme}
        header_up X-Real-IP         {remote_host}
    }
}

Key points:

  • flush_interval -1 is mandatory for SSE — without it Caddy buffers the response and text/event-stream arrives in chunks (sources: Caddy reverse_proxy docs).
  • Authorization header is forwarded by default; do NOT add basicauth / forward_auth — auth is FastAPI’s job.
  • HSTS only after you confirm cert issuance succeeds, otherwise you can lock yourself out (browsers cache HSTS even for ACME-staging-issued certs).

docker-compose.caddy.yml profile

Make Caddy opt-in via Docker Compose profiles: so docker compose up (default) does not pull it.

# docker-compose.caddy.yml — fragment merged into existing compose project
# Activate with:  docker compose --profile caddy up -d
services:
  caddy:
    image: caddy:2-alpine          # multi-arch official image
    profiles: ["caddy"]             # ← opt-in profile
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"               # HTTP/3
    environment:
      WHILLY_PUBLIC_HOST: ${WHILLY_PUBLIC_HOST}   # e.g. 203-0-113-42.sslip.io
    volumes:
      - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data            # cert + ACME account (PERSIST THIS)
      - caddy_config:/config
    networks: [whilly]
    depends_on:
      - whilly-control-plane

volumes:
  caddy_data: {}
  caddy_config: {}

Gotchas:

  • caddy_data volume MUST persist — ACME accounts and issued certs live there. Losing it ⇒ a fresh cert request on next start ⇒ Let’s Encrypt rate-limit territory.
  • Profile flag must be named consistently (--profile caddy); without it, Caddy is invisible to docker compose up (which is what we want for the demo).
  • Compose v2.20+ supports profiles: natively. Older versions silently ignore.

ACME against sslip.io / nip.io

  • sslip.io and nip.io are interchangeable (same operator, same nameservers).
  • Format: <ip-with-dashes>.sslip.io (e.g., 203-0-113-42.sslip.io) or <ip-with-dots>.sslip.io. Dashes are recommended (some software splits on dots).
  • Let’s Encrypt HTTP-01 challenge works (Caddy uses this by default when port 80 is reachable).
  • Wildcard certs are NOT supported by sslip.io (would need DNS-01; nip.io/sslip.io don’t expose a DNS-API). Only single-hostname certs.
  • Rate limits (Let’s Encrypt prod):
    • 50 certs / registered-domain / week — and nip.io / sslip.io count as a single registered domain for everyone. That’s why sslip.io’s homepage explicitly says “if you get rate-limited, file a GitHub issue and we’ll request an increase.” sslip.io has had its rate limit raised by Let’s Encrypt many times.
    • 5 duplicate certs / week
    • 300 new orders / 3 hours / account
  • For demo / dev, prefer acme_ca https://acme-staging-v02.api.letsencrypt.org/directory first to avoid eating into the prod rate limit while iterating. Browsers will warn (untrusted root) — that’s expected.

Recommendation

Use case Choice
Local demo on a public VPS, no domain sslip.io HTTP-01 (acceptable rate-limit risk for low cert churn)
Stable demo URL, control over rate limit duckdns.org HTTP-01 or DNS-01 (per-account subdomain, much smaller blast radius)
Production / repeat issuance Real domain with DNS provider supporting ACME DNS-01

Sources

  • Caddy reverse-proxy quickstart — https://caddyserver.com/docs/quick-starts/reverse-proxy
  • Caddy reverse_proxy directive (incl. flush_interval) — https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
  • Caddy automatic HTTPS — https://caddyserver.com/docs/automatic-https
  • sslip.io homepage (rate-limit + wildcard policy) — https://sslip.io/
  • Let’s Encrypt rate limits — https://letsencrypt.org/docs/rate-limits/
  • Caddy Docker image — https://hub.docker.com/_/caddy
  • Compose profiles — https://docs.docker.com/compose/profiles/

2. Tailscale Funnel

Setup commands (1.52+ syntax)

Funnel exposes one local port to the public internet via a *.ts.net hostname. It can be run on the host or as a sidecar container.

# 1. Install + auth Tailscale on the host
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up --hostname whilly-cp     # opens browser for login

# 2. Confirm Funnel is permitted on this tailnet
#    (Admin console → DNS → enable HTTPS certs; Settings → enable Funnel)

# 3. Start funnel — ports are restricted to 443, 8443, or 10000
sudo tailscale funnel --bg --https=443 http://localhost:8000

# Resulting URL (same shape on every tailnet):
#   https://whilly-cp.<your-tailnet>.ts.net
# Reachable by ANYONE on the public internet, no Tailscale install needed.

# Status / off
tailscale funnel status --json
sudo tailscale funnel --https=443 off

The serve-vs-funnel distinction

Command Audience Auth
tailscale serve Tailnet members only Implicit via WireGuard identity
tailscale funnel Public internet None at the Tailscale layer (TLS terminate only)

tailscale funnel does NOT add an auth layer. It only does TLS termination + reverse-proxy to localhost:<port>. Bearer-token / cookie auth must be enforced by FastAPI itself. Funnel does NOT see / strip the Authorization header.

Public URL reachability

Confirmed: the https://<machine>.<tailnet>.ts.net URL is a normal public hostname (Tailscale runs an authoritative DNS for ts.net). It resolves and serves TLS to any browser, not only Tailscale-connected devices. Funnel uses Let’s Encrypt under the hood; certs are auto-renewed by tailscaled.

Constraints (from official docs)

  • Allowed ports: 443, 8443, 10000 only. No other ports work for HTTPS / TCP-TLS-terminated Funnel. (tailscale funnel command docs.)
  • Funnel must be enabled at the tailnet (Admin) level; it is opt-in.
  • Funnel availability depends on Tailscale plan; Personal/Free includes Funnel for limited use.
  • Reverse-proxy target must be http://127.0.0.1:<port> (or https:///https+insecure://). Other hosts not supported.
  • Persists across reboot only when --bg was used.

Docker pattern: sidecar vs host

Recommendation: tailscale-on-host for our control-plane.

Pattern Pros Cons
Tailscale on host (preferred) Simple; tailscaled runs as system service; zero changes to compose; can also serve other host services Couples to host OS; needs root once for install
Tailscale sidecar container (tailscale/tailscale) Isolated; per-app identity Needs cap_add: net_admin + /dev/net/tun; envs TS_AUTHKEY, TS_HOSTNAME, TS_EXTRA_ARGS=--funnel; PROXY-protocol gymnastics if you also want client IP

If sidecar is needed, the canonical recipe (Tailscale docs):

services:
  tailscale:
    image: tailscale/tailscale:stable
    hostname: whilly-cp
    environment:
      TS_AUTHKEY: ${TS_AUTHKEY}              # ephemeral / reusable from admin console
      TS_EXTRA_ARGS: "--advertise-tags=tag:whilly"
      TS_SERVE_CONFIG: /config/serve.json    # JSON encoding of `tailscale funnel` config
      TS_STATE_DIR: /var/lib/tailscale
    volumes:
      - tailscale_state:/var/lib/tailscale
      - ./deploy/ts-serve.json:/config/serve.json:ro
    cap_add: [net_admin]
    devices: ["/dev/net/tun:/dev/net/tun"]
  whilly-control-plane:
    network_mode: "service:tailscale"        # share network namespace with sidecar
    # ...
volumes:
  tailscale_state: {}

Sources

  • tailscale funnel CLI docs (ports, flags, status, off) — https://tailscale.com/kb/1311/tailscale-funnel
  • tailscale serve docs — https://tailscale.com/docs/reference/tailscale-cli/serve
  • Tailscale Funnel feature page — https://tailscale.com/kb/1223/funnel
  • Funnel examples — https://tailscale.com/docs/reference/examples/funnel
  • Sidecar Docker pattern (TS_SERVE_CONFIG) — https://tailscale.com/kb/1453/quick-guide-docker

3. asyncpg LISTEN/NOTIFY → SSE

The canonical pattern

One dedicated asyncpg connection subscribes to a Postgres channel and fans out into per-subscriber asyncio.Queue instances. SSE handlers await queue.get() and yield events.

Why one connection: asyncpg.Pool connections are reused; LISTEN state lives on the connection, so a pooled connection that gets returned would silently lose subscriptions. Pin LISTEN to a dedicated long-lived connection outside the pool.

Trigger SQL

-- Generic notify-on-change trigger.
-- Payload: JSON ({op, id, plan_id, ts}). Stay under 8000 bytes/payload (Postgres limit)
-- — store full row in events table; payload contains only the lookup key.
CREATE OR REPLACE FUNCTION notify_event() RETURNS trigger AS $$
DECLARE
    payload JSONB;
BEGIN
    payload := jsonb_build_object(
        'op',      TG_OP,
        'id',      NEW.id,
        'plan_id', NEW.plan_id,
        'ts',      extract(epoch FROM now())
    );
    PERFORM pg_notify('whilly_events', payload::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS events_notify ON events;
CREATE TRIGGER events_notify
AFTER INSERT ON events
FOR EACH ROW EXECUTE FUNCTION notify_event();

Multiple channels (e.g., per plan_id) work, but one channel + filter in app code is far simpler and scales to thousands of plans.

Server pattern (the broker)

# whilly_orchestrator/control_plane/sse_broker.py
import asyncio
import json
import logging
from contextlib import asynccontextmanager

import asyncpg

log = logging.getLogger(__name__)


class EventBroker:
    """One asyncpg LISTEN connection -> per-subscriber asyncio.Queue."""

    def __init__(self, dsn: str, channel: str = "whilly_events") -> None:
        self._dsn = dsn
        self._channel = channel
        self._conn: asyncpg.Connection | None = None
        self._subscribers: set[asyncio.Queue[dict]] = set()
        self._task: asyncio.Task | None = None

    async def start(self) -> None:
        # Dedicated connection, NOT from a pool.
        self._conn = await asyncpg.connect(self._dsn)
        await self._conn.add_listener(self._channel, self._on_notify)
        log.info("SSE broker listening on channel=%s", self._channel)

    async def stop(self) -> None:
        if self._conn is not None:
            await self._conn.remove_listener(self._channel, self._on_notify)
            await self._conn.close()
        for q in list(self._subscribers):
            q.put_nowait({"type": "__close__"})
        self._subscribers.clear()

    def _on_notify(self, _conn, _pid, _channel, payload: str) -> None:
        try:
            event = json.loads(payload)
        except json.JSONDecodeError:
            log.warning("non-JSON payload on %s: %s", self._channel, payload[:200])
            return
        # Fan out. Drop on full queue (slow client) — never block the LISTEN coroutine.
        for q in self._subscribers:
            try:
                q.put_nowait(event)
            except asyncio.QueueFull:
                log.warning("dropping event for slow subscriber")

    @asynccontextmanager
    async def subscribe(self, maxsize: int = 256):
        q: asyncio.Queue[dict] = asyncio.Queue(maxsize=maxsize)
        self._subscribers.add(q)
        try:
            yield q
        finally:
            self._subscribers.discard(q)

FastAPI integration with sse-starlette

sse-starlette (pip install sse-starlette) is the de-facto choice — it implements correct flushing, heartbeat, Last-Event-ID parsing, and keeps the connection alive through proxies.

# whilly_orchestrator/control_plane/api/events.py
from fastapi import APIRouter, Depends, Request
from sse_starlette.sse import EventSourceResponse

from ..sse_broker import EventBroker
from ..deps import get_broker, get_repo

router = APIRouter()


@router.get("/events/stream")
async def stream_events(
    request: Request,
    broker: EventBroker = Depends(get_broker),
    repo = Depends(get_repo),
):
    last_event_id = request.headers.get("Last-Event-ID")  # int as string

    async def event_generator():
        # 1. Reconcile: replay anything we missed since last_event_id.
        if last_event_id and last_event_id.isdigit():
            async for ev in repo.iter_events_since(int(last_event_id)):
                yield {"id": str(ev.id), "event": ev.kind, "data": ev.payload_json}

        # 2. Subscribe to live stream.
        async with broker.subscribe() as queue:
            while True:
                if await request.is_disconnected():
                    break
                try:
                    ev = await asyncio.wait_for(queue.get(), timeout=15.0)
                except asyncio.TimeoutError:
                    yield {"event": "ping", "data": ""}    # heartbeat
                    continue
                if ev.get("type") == "__close__":
                    break
                # Look up the row (payload only carries the id) and serialize HTML / JSON.
                row = await repo.fetch_event(ev["id"])
                yield {
                    "id": str(row.id),
                    "event": row.kind,
                    "data": row.html_fragment,        # for hx-sse swap; or JSON for native EventSource
                }

    return EventSourceResponse(event_generator(), ping=15)

Lifespan wiring

# whilly_orchestrator/control_plane/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from .sse_broker import EventBroker
from .config import settings

@asynccontextmanager
async def lifespan(app: FastAPI):
    broker = EventBroker(settings.database_url)
    await broker.start()
    app.state.broker = broker
    try:
        yield
    finally:
        await broker.stop()

app = FastAPI(lifespan=lifespan)

Reconnect strategy (Last-Event-ID)

LISTEN/NOTIFY is lossy across reconnects — Postgres does not buffer notifies for a disconnected listener, and our broker is in-process (no delivery guarantee).

The robust pattern:

  1. Persist every domain event to an events table with a monotonically-increasing id (bigserial or bigint generated always as identity).
  2. The notify trigger fires only on insert, payload includes id.
  3. SSE response sets id: header on every message (mirrors events.id).
  4. Browser reconnect automatically sends Last-Event-ID.
  5. Server replays SELECT * FROM events WHERE id > :last_event_id ORDER BY id before subscribing to the live broker.
  6. There is a tiny race between “reconcile finished” and “subscribe started” — solve by reading MAX(id) before subscribe and re-running the reconcile if any rows arrived between SELECT and subscribe (or just deduplicate on the client by id).

Connection pool exhaustion

  • The LISTEN connection is separate from asyncpg.Pool. Don’t burn pool slots on it.
  • Each SSE request must NOT hold a pool connection for its lifetime — only reach into the pool transiently for the reconcile and per-event lookups, then await conn.release() immediately.
  • For high concurrency, consider increasing pool.max_size and lowering it back via pool.acquire(timeout=2) so a slow SSE handler doesn’t starve the rest of the API.

Sources

  • asyncpg add_listener / LISTEN — https://magicstack.github.io/asyncpg/current/api/index.html#asyncpg.connection.Connection.add_listener
  • Postgres pg_notify (8 KB payload limit) — https://www.postgresql.org/docs/current/sql-notify.html
  • sse-starlette (FastAPI integration, EventSourceResponse) — https://github.com/sysid/sse-starlette
  • FastAPI SSE example — https://fastapi.tiangolo.com/tutorial/server-sent-events/
  • MDN EventSource (spec for Last-Event-ID) — https://developer.mozilla.org/en-US/docs/Web/API/EventSource

4. Prometheus /metrics for control-plane

prometheus-fastapi-instrumentator (PyPI: prometheus-fastapi-instrumentator, latest 7.1.0). It gives you the canonical HTTP-level metrics (request count / latency / size) for free, plus a clean hook (add()) for our custom ones. Uses prometheus_client underneath, so any custom metrics you create with Counter / Gauge / Histogram register on the same registry.

Hand-rolled make_asgi_app() is fine but you’ll re-implement label sanitization, untemplated-route grouping, and inprogress gauge — all of which the instrumentator already covers.

Setup

# whilly_orchestrator/control_plane/observability.py
from prometheus_client import Counter, Gauge, Histogram
from prometheus_fastapi_instrumentator import Instrumentator

# ── Custom metrics — registered on the default registry ───────────────────────
CLAIMS_TOTAL = Counter(
    "whilly_claims_total",
    "Tasks successfully claimed by a worker.",
    labelnames=("plan_id", "worker_id"),
)
COMPLETES_TOTAL = Counter(
    "whilly_completes_total",
    "Tasks completed successfully.",
    labelnames=("plan_id", "worker_id"),
)
FAILS_TOTAL = Counter(
    "whilly_fails_total",
    "Tasks reported failed.",
    labelnames=("plan_id", "worker_id", "reason"),
)
WORKERS_ONLINE = Gauge(
    "whilly_workers_online",
    "Number of workers with last_heartbeat < 30s.",
)
CLAIMS_PENDING = Gauge(
    "whilly_claims_pending",
    "Tasks in 'pending' status.",
    labelnames=("plan_id",),
)
PLAN_BUDGET_REMAINING_USD = Gauge(
    "whilly_plan_budget_remaining_usd",
    "Remaining budget per plan (USD).",
    labelnames=("plan_id",),
)
CLAIM_LONG_POLL_DURATION = Histogram(
    "whilly_claim_long_poll_duration_seconds",
    "Time a claim long-poll waits before returning a task or empty response.",
    buckets=(0.1, 0.25, 0.5, 1, 2, 5, 10, 30, 60),
)

def setup_metrics(app):
    Instrumentator(
        should_group_status_codes=True,
        should_ignore_untemplated=True,        # avoid label explosion
        excluded_handlers=["/metrics", "/healthz"],
        inprogress_name="whilly_http_inprogress",
        inprogress_labels=False,
    ).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)

Emitting from a repository class

Plain attribute access — Counters/Gauges are module-level singletons:

# whilly_orchestrator/control_plane/repositories.py
from .observability import CLAIMS_TOTAL, COMPLETES_TOTAL, FAILS_TOTAL

class TaskRepository:
    def __init__(self, pool): self._pool = pool

    async def claim(self, worker_id: str) -> Task | None:
        async with self._pool.acquire() as conn:
            row = await conn.fetchrow(SQL_CLAIM, worker_id)
        if row:
            CLAIMS_TOTAL.labels(plan_id=row["plan_id"], worker_id=worker_id).inc()
        return Task(**row) if row else None

For gauges that reflect aggregate state (e.g., WORKERS_ONLINE), it’s cleaner to update them inside a periodic background task that runs SELECT count(*) FROM workers WHERE last_heartbeat > now() - interval '30 seconds' rather than from event hot-paths.

Auth posture for /metrics

Threat model split:

Deployment Recommendation
Behind Tailscale Funnel (public TLS, but tailnet-curated identity for management UI) Require bearer auth on /metrics, identical to the rest of the API. Public-internet-visible metrics endpoints leak business signals.
Behind Caddy on a public IP (sslip.io) Definitely require auth. Either bearer or IP allowlist via Caddy @allowed remote_ip 10.0.0.0/8 matcher.
Tailscale-only (tailscale serve, no Funnel) Optional — tailnet identity is enough; bearer is overkill.

Implementation: don’t add excluded_handlers=["/metrics"] — instead leave /metrics covered by the auth dependency. With FastAPI, attach dependencies=[Depends(require_bearer)] to the included instrumentator.expose(...) route, or wrap with middleware that lets Prometheus scrape with a dedicated bearer (WHILLY_METRICS_TOKEN).

# Bearer-on-/metrics example
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials

bearer_scheme = HTTPBearer(auto_error=False)

async def require_metrics_token(
    creds: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
):
    expected = settings.metrics_token
    if not creds or creds.credentials != expected:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED)

# Then wire after instrument():
instrumentator.expose(app, endpoint="/metrics", dependencies=[Depends(require_metrics_token)])

Sources

  • prometheus-fastapi-instrumentator README — https://github.com/trallnag/prometheus-fastapi-instrumentator
  • prometheus_client (Counter / Gauge / Histogram API) — https://github.com/prometheus/client_python
  • Prometheus instrumentation best practices — https://prometheus.io/docs/practices/instrumentation/
  • Prometheus naming conventions — https://prometheus.io/docs/practices/naming/

5. HTMX patterns for live dashboards

Polling vs SSE

Approach When to use
hx-trigger="every 2s" + hx-get="/dashboard/workers/fragment" Default. Simple, survives proxies / mobile networks well, no server-side state.
hx-ext="sse" sse-connect="..." sse-swap="EventName" When sub-second updates matter or you have many clients (broadcast cheaper than per-client polling).

Concrete dashboard recipe (mixed): poll for the table outline every 5s; SSE for per-row deltas.

Polling pattern (full-fragment row swap)

<!-- templates/dashboard.html (Jinja2) -->
<table id="workers">
  <thead><tr><th>id</th><th>status</th><th>last seen</th></tr></thead>
  <tbody hx-get="/dashboard/workers/fragment"
         hx-trigger="every 5s"
         hx-swap="innerHTML">
    {% include "_worker_rows.html" %}
  </tbody>
</table>
# control_plane/api/dashboard.py
from fastapi import APIRouter, Depends, Request
from fastapi.templating import Jinja2Templates

router = APIRouter()
templates = Jinja2Templates(directory="templates")

@router.get("/dashboard/workers/fragment")
async def workers_fragment(request: Request, repo = Depends(get_repo)):
    workers = await repo.list_workers()
    return templates.TemplateResponse(
        "_worker_rows.html",
        {"request": request, "workers": workers},
    )

SSE pattern (htmx-ext-sse 2.x)

<head>
  <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.10/dist/htmx.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/htmx-ext-sse@2.2.4"></script>
</head>
<body hx-ext="sse">
  <table>
    <tbody sse-connect="/events/stream"
           sse-swap="worker_update,worker_remove,task_claimed">
      <tr id="worker-row-abc"
          hx-target="this"
          hx-swap="outerHTML">
        <td>abc</td><td>online</td><td>3s ago</td>
      </tr>
    </tbody>
  </table>
</body>

Server emits an SSE event like:

id: 14021
event: worker_update
data: <tr id="worker-row-abc" hx-target="this" hx-swap="outerHTML"><td>abc</td><td>online</td><td>1s ago</td></tr>

The HTML fragment must include id="worker-row-..." so HTMX OOB-style targeting (and hx-swap="outerHTML") replaces only that row. No JSON gymnastics.

Jinja2 in FastAPI

from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles

templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")

Hot reload: uvicorn ... --reload --reload-dir templates --reload-dir static picks up template / static changes. (Jinja2’s autoescape is on by default for .html — keep it that way.)

“Minimal CSS” recommendation

For a worker / sysadmin dashboard:

  1. pico.css (classless) — cdn import, every default HTML element looks reasonable, dark-mode auto. Best dev-effort/look ratio.
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.classless.min.css">
    
  2. Inline <style> block in base.html — for ~20 lines of bespoke CSS. Avoids a build step.
  3. Raw HTML — only acceptable if the dashboard is truly internal (5 ops humans). Not recommended; pico.css adds zero burden.

Pick option 1 (pico.css classless) — it’s CDN-loadable, ~20 KB gzipped, and doesn’t fight HTMX swaps.

Sources

  • HTMX SSE extension (2.x, current) — https://htmx.org/extensions/sse/
  • HTMX hx-trigger (every 2s) — https://htmx.org/attributes/hx-trigger/
  • HTMX 2.0 release notes — https://htmx.org/posts/2024-06-17-htmx-2-0-0-is-released/
  • FastAPI templates (Jinja2) — https://fastapi.tiangolo.com/advanced/templates/
  • pico.css — https://picocss.com/

6. OS keychain integration in Python

keyring — auto-detected backends

pip install keyring. The library picks a backend at import-time based on platform:

Platform Backend Library used
macOS Keychain (Apple Security framework) none extra (built-in Foundation via pyobjc-framework-free path on recent versions)
Windows Windows Credential Locker pywin32-ctypes
Linux (GNOME / KDE) Secret Service (D-Bus) secretstorage (transitively jeepney)
Linux (KDE alternative) KWallet dbus-python (often fails to compile on pip install)
Headless server / no D-Bus keyring.backends.fail.Keyring n/a

Discovery order: keyring.get_keyring() returns the chosen backend after probing. Override via env var PYTHON_KEYRING_BACKEND=keyring.backends.SecretService.Keyring or programmatically with keyring.set_keyring(...).

Idiomatic API

import keyring

SERVICE = "whilly-orchestrator"

# Store a per-worker bearer
keyring.set_password(SERVICE, f"worker:{worker_id}", token)

# Retrieve
token = keyring.get_password(SERVICE, f"worker:{worker_id}")    # None if absent

# Remove
try:
    keyring.delete_password(SERVICE, f"worker:{worker_id}")
except keyring.errors.PasswordDeleteError:
    pass

Fallback file (no usable backend)

Detect the fail.Keyring backend and fall through to a chmod-600 JSON store:

# whilly_orchestrator/cli/secrets.py
import json
import os
import stat
from pathlib import Path

import keyring
from keyring.backends.fail import Keyring as FailKeyring

SERVICE = "whilly-orchestrator"
FALLBACK_PATH = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) \
                / "whilly" / "credentials.json"


def _backend_works() -> bool:
    return not isinstance(keyring.get_keyring(), FailKeyring)


def _read_fallback() -> dict[str, str]:
    if not FALLBACK_PATH.exists():
        return {}
    return json.loads(FALLBACK_PATH.read_text(encoding="utf-8"))


def _write_fallback(data: dict[str, str]) -> None:
    FALLBACK_PATH.parent.mkdir(parents=True, exist_ok=True)
    tmp = FALLBACK_PATH.with_suffix(".tmp")
    tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
    os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)        # 0o600
    tmp.replace(FALLBACK_PATH)


def store_token(key: str, value: str) -> None:
    if _backend_works():
        keyring.set_password(SERVICE, key, value)
    else:
        data = _read_fallback()
        data[key] = value
        _write_fallback(data)


def get_token(key: str) -> str | None:
    if _backend_works():
        return keyring.get_password(SERVICE, key)
    return _read_fallback().get(key)

Gotchas

  • On macOS, the first call from a new binary triggers a Keychain UI prompt (“allow whilly to access keychain”); user must approve. Subsequent calls in the same session are silent.
  • On Linux, the Secret Service backend requires a unlocked D-Bus session bus; on a headless box without a desktop environment the fail.Keyring is selected — fallback path is mandatory.
  • Some CI runners (secrets unavailable) — use WHILLY_TOKEN env var as the highest-priority lookup before keyring.
  • Don’t store the token in process env then forward to subprocesses without scrubbing logs.

Sources

  • keyring PyPI / docs — https://pypi.org/project/keyring/, https://keyring.readthedocs.io/
  • macOS Keychain Services overview — https://developer.apple.com/documentation/security/keychain_services
  • Linux Secret Service spec — https://specifications.freedesktop.org/secret-service/

7. Free subdomain for ACME

Comparison

Service Format DNS-01 (wildcard) HTTP-01 Account needed Rate-limit shielding
sslip.io / nip.io <ip-w-dashes>.sslip.io LE rate-limit increases granted on request
traefik.me <ip>.traefik.me Smaller / less proven
DuckDNS <name>.duckdns.org ✅ via DNS-01 with token ✅ (free, GitHub OAuth) LE prod rate limits apply per-account; effectively unbounded for typical use
freedns.afraid.org <name>.<your-pick>.<tld> depends on subdomain owner Variable

For automated Caddy + Let’s Encrypt against a VPS public IP, the right answer depends on the deploy stability:

Need Pick
Demo / one-off, no signup, public IP fixed sslip.io (or nip.io) HTTP-01
Reusable demo, IP may change, OK with signup DuckDNS + Caddy DNS-01 (caddy-dns/duckdns plugin) — wildcard + per-account rate limits, IP refresh by GET to update URL

sslip.io setup (zero-signup)

# Find your IP
PUBLIC_IP=$(curl -fsSL https://ifconfig.me)

# Convert dots→dashes for safety
HOST="${PUBLIC_IP//./-}.sslip.io"     # e.g., 203-0-113-42.sslip.io
echo "$HOST"

# Ensure inbound 80 + 443 reachable on the VPS, then start Caddy
WHILLY_PUBLIC_HOST="$HOST" docker compose --profile caddy up -d

Caddy will solve HTTP-01, get a Let’s Encrypt cert in seconds, and all subsequent requests get a real green padlock.

DuckDNS setup (account, wildcard-capable)

  1. Sign up at https://www.duckdns.org/ (GitHub login).
  2. Create a name, e.g., whilly-demo → resolves to whilly-demo.duckdns.org.
  3. Get your account token from the duckdns.org page.
  4. Use a Caddy build that includes the DuckDNS DNS plugin (caddy-dns/duckdns) — easiest via the official caddy:builder image or xcaddy.
  5. Caddyfile uses tls { dns duckdns {env.DUCKDNS_TOKEN} } for DNS-01 (wildcards possible: *.whilly-demo.duckdns.org).
  6. A side-channel cron / systemd timer pings https://www.duckdns.org/update?domains=whilly-demo&token=...&ip= to keep the A-record current if dynamic.

Rate-limit considerations

  • sslip.io and nip.io cert-issuance shares one global Let’s Encrypt registered-domain bucket. Short bursts of issuance from many users can rate-limit you even if you only request once. Mitigation: stable IP → cert is reused for 90 days.
  • DuckDNS is per-account-name → effectively your own bucket. Strongly preferred for repeatedly-issuing CI environments.
  • Always test against acme-staging-v02 first.

Sources

  • sslip.io homepage — https://sslip.io/
  • nip.io memorial page — https://nip.io/
  • DuckDNS — https://www.duckdns.org/
  • Caddy DuckDNS module (caddy-dns/duckdns) — https://github.com/caddy-dns/duckdns
  • Let’s Encrypt rate limits — https://letsencrypt.org/docs/rate-limits/
  • Let’s Encrypt staging — https://letsencrypt.org/docs/staging-environment/

Summary: gotchas + non-obvious decisions

  • Caddy SSE requires flush_interval -1 in reverse_proxy plus read_timeout 24h. Without both, SSE arrives as one giant chunk after disconnect.
  • Tailscale Funnel terminates TLS but adds NO auth. Funnel is only safe to expose if FastAPI itself enforces bearer / mTLS. Funnel ports are restricted to 443 / 8443 / 10000.
  • LISTEN/NOTIFY is lossy. Always pair with an events table + Last-Event-ID reconciliation. Pin LISTEN to a dedicated asyncpg connection (NOT pool) or you’ll silently lose subscriptions when the pool churns.
  • sslip.io/nip.io share ONE Let’s Encrypt rate-limit bucket for the whole world. For low cert churn it’s fine; for CI / demo-on-every-PR use DuckDNS or a real domain.
  • prometheus-fastapi-instrumentator is the right default, but lock down /metrics with bearer auth when behind any public TLS terminator (Caddy, Funnel). Use a dedicated WHILLY_METRICS_TOKEN so Prometheus scrapers don’t share the worker bearer.
  • HTMX SSE extension is now htmx-ext-sse@2.2.4 (separate package; hx-sse attribute is gone). Use hx-ext="sse" sse-connect=... sse-swap=.... Server fragments must include the row id and hx-swap="outerHTML" for surgical updates.
  • keyring falls back silently to a no-op (fail.Keyring) on headless Linux. Detect with isinstance(keyring.get_keyring(), FailKeyring) and route to a chmod-600 file under ~/.config/whilly/credentials.json. Always allow an env-var override (WHILLY_TOKEN) for CI.
  • Caddy caddy_data volume MUST persist across container restarts — losing it forces reissuance and risks rate-limits.
  • Funnel sidecar containers need cap_add: NET_ADMIN, /dev/net/tun, and network_mode: service:tailscale on the FastAPI container. On-host install is simpler — pick that unless you have a strong isolation requirement.
  • Postgres NOTIFY payloads are capped at 8000 bytes. Always carry only an id + minimal metadata; full row goes through the events table on the SSE side.