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>
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 vianetwork_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 localxcaddybuild, 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)
- Postgres role + db:
stalwart/STALWART_DB_NAME. Create via the federatedSocialbootstrap.shflow or a one-offCREATE ROLE … LOGIN; CREATE DATABASE … OWNER …. - Garage bucket
stalwart-mail+ grant the shared access key access to it. - Redis: nothing to create — just use a dedicated logical DB index
(
STALWART_REDIS_DB) so we don't collide with the apps. - Admin console: assign
tag:stalwartto the OAuth client (Devices/Core + Keys/AuthKeys) and addacl-snippet.hujsonto 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.tomlis 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 over127.0.0.1against these Postgres containers: pg_hbatrusts 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.
:25must 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.envif you must; never write there. - Don't add
ports:to the Stalwart container — the edge proxy is the only public surface, and it lives incaddy/. - Don't commit
.envor a built Caddy binary (see.gitignore). - Don't break the sidecar netns boundary with bridge networks or host ports.