tailwart/CLAUDE.md
Wayne Hayes a9e2a736fc caddy: build via caddyserver.com download URL, not local xcaddy
The xcaddy/Go compile burns ~1GB RAM this VPS can't spare (per ~/docs/caddy.md
"Custom Binary"). Pull the prebuilt L4-enabled binary from the Caddy build
server instead and swap it over the stock binary in the official image. Built
and verified: caddy v2.11.3 with layer4.handlers.proxy + proxy_protocol.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:39:33 -04:00

5.2 KiB

CLAUDE.md — tailwart

Guidance for Claude Code in this repo. Read before editing.

What this is

A play deployment of Stalwart (all-in-one mail/JMAP/IMAP/SMTP server) wired, gratuitously, into three shared backends — Postgres, Redis, and Garage S3 — to see how far the federatedSocial Tailscale-sidecar pattern stretches past the fediverse apps. Target domain: infinidim.net (may become real later).

It is self-contained and outside /opt/federatedSocial on purpose: that's an upstream clone that git pull overwrites. tailwart owns its own .env, compose, config, ACL snippet, and Caddy build, and only reads from the tailnet (shared infra over MagicDNS) at runtime.

Architecture — two ends of one wire

  public IP host (tag:reverse-proxy)            tailnet-only mailbox
  ┌───────────────────────────┐                ┌────────────────────────┐
  │ caddy/  (caddy-l4)         │   tailnet      │ ts-stalwart sidecar     │
  │  :25 :465 :587 :143 :993 ──┼───WireGuard───▶│  stalwart (no WAN, no   │
  │  PROXY protocol v2         │                │  host ports)            │
  └───────────────────────────┘                └───────────┬────────────┘
        L7 JMAP vhost on the main Caddy                     │
        mail.infinidim.net → :8080                  ┌───────┴───────┐
                                                    ▼       ▼       ▼
                                              Postgres   Redis   Garage S3
                                            (the-record)(slo-time)(garage)
  • Mailbox (docker-compose.yml): Stalwart in a Tailscale sidecar via network_mode: service:ts-stalwart. Binds nothing on the host. All mail ports listen on the tailnet only.
  • Edge (caddy/): a layer-4 TCP proxy (Caddy + caddy-l4, pulled prebuilt from caddyserver.com — no local xcaddy build, per ~/docs/caddy.md). Pure pass-through; Stalwart owns TLS. Can run on a different machine than the mailbox — the key idea.
  • Backends: data+fts → Postgres, blob → Garage S3, lookup/in-memory → Redis. One stalwart role/db, one Garage bucket, one Redis logical DB.

The .env contract

.env (gitignored) is the whole operator surface; .env.example is the template. Both compose files read it. Secrets reach Stalwart as env vars and are referenced from config/config.toml via %{env:NAME}% so the toml stays commit-safe. Never hardcode a value that belongs in .env — except the two spots a static file forces it: caddy/caddy.json dial targets and any MagicDNS host in the toml.

Sidecar boilerplate

Identical to federatedSocial's (TS_ACCEPT_DNS true, kernel networking, 127.0.0.1 healthcheck, ephemeral OAuth auth). Don't drift it. Tag: tag:stalwart.

Prerequisites (shared tailnet infra — already running for the fediverse)

  1. Postgres role + db: stalwart / STALWART_DB_NAME. Create via the federatedSocial bootstrap.sh flow or a one-off CREATE ROLE … LOGIN; CREATE DATABASE … OWNER ….
  2. Garage bucket stalwart-mail + grant the shared access key access to it.
  3. Redis: nothing to create — just use a dedicated logical DB index (STALWART_REDIS_DB) so we don't collide with the apps.
  4. Admin console: assign tag:stalwart to the OAuth client (Devices/Core + Keys/AuthKeys) and add acl-snippet.hujson to the policy.

Pitfalls (some learned the hard way next door)

  • Mail edge is layer 4, not layer 7. Don't try to give the L4 ports a normal Caddy vhost. SNI/Host routing doesn't apply to :25.
  • PROXY protocol or your mail reputation dies. Without it Stalwart sees the proxy's tailnet IP as every client → SPF/DNSBL/greylisting break. Both ends must agree (caddy.json proxy_protocol: v2 ↔ config [server.proxy] trusted-networks).
  • Stalwart config drifts between versions and migrates into the admin store after first boot. config/config.toml is a strawman — verify keys against the pinned image tag before trusting them. Pin the tag once it works.
  • POSTGRES_PASSWORD/role passwords only apply on an empty volume. If a password "doesn't work," the stored credential drifted — ALTER USER, don't re-init. And never test a password over 127.0.0.1 against these Postgres containers: pg_hba trusts loopback and accepts ANY password. Test over the tailnet (scram) or you'll fool yourself.
  • Outbound :25 is usually blocked on VPS. Set STALWART_SMARTHOST.
  • Mail forces WAN ports. :25 must be world-reachable for inbound federation — this is the one place the tailnet-only model can't hold. Keep submission/IMAP tailnet-only if you want a tighter surface.

What not to do

  • Don't put files in /opt/federatedSocial. Read its .env if you must; never write there.
  • Don't add ports: to the Stalwart container — the edge proxy is the only public surface, and it lives in caddy/.
  • Don't commit .env or a built Caddy binary (see .gitignore).
  • Don't break the sidecar netns boundary with bridge networks or host ports.