Scaffold tailwart: Stalwart mailbox as a Tailscale sidecar

Over-engineered play stack for infinidim.net — Stalwart wired into the shared
Postgres + Redis + Garage S3 over the tailnet, with no WAN presence. Public
mail ports are fronted by a separate caddy-l4 layer-4 proxy (caddy/) that can
run on any tailnet host tagged tag:reverse-proxy — decoupled from the mailbox.

- docker-compose.yml: ts-stalwart sidecar + stalwart, backends via MagicDNS
- config/config.toml: PG (data/fts) + Redis (lookup) + S3 (blob) strawman
- caddy/: xcaddy build with caddy-l4, JSON layer-4 mail proxy, own compose
- acl-snippet.hujson: tag:stalwart owner + backend/edge grants
- .env.example + gitignored .env (pulled from shared infra)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Wayne Hayes 2026-06-03 22:25:38 -04:00
commit 2eb8a0c225
11 changed files with 579 additions and 0 deletions

79
.env.example Normal file
View File

@ -0,0 +1,79 @@
# ============================================================================
# tailwart — operator configuration (Tailscale × Stalwart)
# ============================================================================
# Copy to .env and fill in. The mailbox stack (docker-compose.yml) and the
# edge proxy (caddy/docker-compose.yml) both read their values from here.
#
# Design: Stalwart runs as a Tailscale sidecar with NO WAN presence. A
# layer-4 (TCP) Caddy proxy on a public-IP host pipes the raw mail ports to it
# over the tailnet. The proxy can live on a DIFFERENT machine than the mailbox
# — that's the whole point. See CLAUDE.md.
# ============================================================================
# ----------------------------------------------------------------------------
# Tailscale — same OAuth client as the rest of the tailnet
# ----------------------------------------------------------------------------
# Sidecar advertises tag:stalwart. Assign that tag to the OAuth client on both
# the Devices/Core and Keys/AuthKeys scopes, or node creation 403s at boot.
TS_OAUTH_CLIENT_SECRET=
TS_TAILNET=tailfe8c.ts.net
# MagicDNS hostname for the Stalwart sidecar → reachable at
# ${STALWART_MAGIC_NAME}.${TS_TAILNET} over the tailnet.
STALWART_MAGIC_NAME=stalwart
# ----------------------------------------------------------------------------
# Mail identity
# ----------------------------------------------------------------------------
# The domain you send/receive for, and the public MX/hostname clients connect
# to (its A record points at the EDGE PROXY's public IP, not the mailbox).
STALWART_DOMAIN=infinidim.net
STALWART_HOSTNAME=mail.infinidim.net
# Initial admin password for the Stalwart web console (first boot only).
# Generate: openssl rand -base64 24
STALWART_FALLBACK_ADMIN_SECRET=
# ----------------------------------------------------------------------------
# Over-engineering, part 1/3 — Postgres (shared instance on the tailnet)
# ----------------------------------------------------------------------------
# Stalwart's metadata + full-text store. Create the role/db once (see README).
DB_MAGIC_NAME=the-record-prod
STALWART_DB_NAME=stalwart
STALWART_DB_USER=stalwart
# Generate: openssl rand -base64 24 (avoid / and + if you ever inline it in a URL)
STALWART_DB_PASSWORD=
# ----------------------------------------------------------------------------
# Over-engineering, part 2/3 — Redis (shared instance on the tailnet)
# ----------------------------------------------------------------------------
# Stalwart's in-memory store: distributed rate limits, caches, token revocation.
REDIS_MAGIC_NAME=slo-time-prod
# Use a dedicated logical DB index so we don't collide with the fediverse apps.
STALWART_REDIS_DB=3
# ----------------------------------------------------------------------------
# Over-engineering, part 3/3 — Garage S3 (shared instance on the tailnet)
# ----------------------------------------------------------------------------
# Stalwart's blob store: message bodies, attachments, sieve scripts.
# Reuses the shared Garage access key; needs its own bucket (see README).
GARAGE_MAGIC_NAME=garage
GARAGE_REGION=garage
GARAGE_ACCESS_KEY_ID=
GARAGE_SECRET_ACCESS_KEY=
STALWART_S3_BUCKET=stalwart-mail
# ----------------------------------------------------------------------------
# Outbound delivery
# ----------------------------------------------------------------------------
# Most VPS providers block outbound :25. If yours does, relay through a
# smarthost (host:port). Leave blank to attempt direct MX delivery.
STALWART_SMARTHOST=
# ----------------------------------------------------------------------------
# TLS — Stalwart self-manages certs via ACME DNS-01 (works behind the L4 proxy)
# ----------------------------------------------------------------------------
# DNS provider + token for the DNS-01 challenge. Leave blank to instead mount
# a certbot-issued cert (see config/config.toml [certificate]).
STALWART_ACME_PROVIDER=
STALWART_ACME_TOKEN=

11
.gitignore vendored Normal file
View File

@ -0,0 +1,11 @@
# Operator secrets — never commit. Copy .env.example -> .env and fill in.
.env
caddy/.env
# Local-only overrides
*.local.toml
*.local.json
# Built Caddy binary (rebuild from caddy/Dockerfile instead of committing 50MB)
caddy/caddy
caddy/*.bin

94
CLAUDE.md Normal file
View File

@ -0,0 +1,94 @@
# 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. 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 `trust`s 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.

50
README.md Normal file
View File

@ -0,0 +1,50 @@
# tailwart
> Tailscale × Stalwart. A mailbox with no WAN presence, fronted by a layer-4
> proxy that can live on another machine entirely.
A deliberately over-engineered playground: [Stalwart](https://github.com/stalwartlabs/stalwart)
mail server wired into **Postgres + Redis + Garage S3** at once, deployed as a
Tailscale sidecar, with a separate `caddy-l4` edge that pipes the raw mail ports
over the tailnet. For `infinidim.net`.
See [CLAUDE.md](./CLAUDE.md) for the architecture and the gotchas.
## Layout
```
tailwart/
├── docker-compose.yml # the mailbox: ts-stalwart sidecar + stalwart
├── config/config.toml # Stalwart config — PG + Redis + S3 wiring (strawman)
├── caddy/ # the edge: custom Caddy (caddy-l4) layer-4 mail proxy
│ ├── Dockerfile # xcaddy build with caddy-l4 + ratelimit
│ ├── caddy.json # :25/465/587/143/993 → stalwart over the tailnet
│ ├── docker-compose.yml # deploy on any public-IP, tailnet, tag:reverse-proxy host
│ └── README.md
├── acl-snippet.hujson # tag:stalwart owner + grants to merge into your policy
├── .env.example # operator surface — copy to .env
└── .gitignore
```
## Quickstart
```bash
cp .env.example .env && $EDITOR .env # fill secrets (see CLAUDE.md prereqs)
# 1. create the stalwart role/db in shared Postgres + the Garage bucket
# (one-off; see CLAUDE.md "Prerequisites")
# 2. admin console: assign tag:stalwart to the OAuth client + paste acl-snippet
# 3. bring up the mailbox (tailnet-only)
docker compose up -d
# 4. bring up the edge (binds public mail ports; can be a different host)
cd caddy && docker compose up -d --build
```
Then point `infinidim.net`'s MX at the edge host, add SPF/DKIM/DMARC, and finish
configuration in Stalwart's web admin (`mail.infinidim.net`).
## Status
Scaffold / strawman. The Stalwart `config.toml` keys need verifying against a
pinned image version before first real boot — treat it as a starting shape, not
a turnkey config.

23
acl-snippet.hujson Normal file
View File

@ -0,0 +1,23 @@
// tailwart — merge into your live Tailscale policy (admin console).
// Snippet, not a full policy. Kept here so an upstream pull of any other repo
// can't clobber it.
// 1) tagOwners — add (self-ownership required for auth-key node creation):
// "tag:stalwart": ["autogroup:admin", "tag:stalwart"],
// 2) grants — Stalwart reaches the three shared backends:
{ "src": ["tag:stalwart"], "dst": ["tag:db-postgres"], "ip": ["tcp:5432"] },
{ "src": ["tag:stalwart"], "dst": ["tag:db-redis"], "ip": ["tcp:6379"] },
{ "src": ["tag:stalwart"], "dst": ["tag:garage"], "ip": ["tcp:3900"] },
// 3) grants — the edge proxy (tag:reverse-proxy) reaches the mailbox ports.
// 8080 is the JMAP/admin HTTP tier (fronted by the main L7 Caddy).
{
"src": ["tag:reverse-proxy"],
"dst": ["tag:stalwart"],
"ip": ["tcp:25", "tcp:465", "tcp:587", "tcp:143", "tcp:993", "tcp:8080"],
},
// 4) admin console (not this file): assign tag:stalwart to the same OAuth
// client federatedSocial uses, on the Devices/Core + Keys/AuthKeys scopes.
// Missing → 403 "calling actor does not have enough permissions" at boot.

15
caddy/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
# Custom Caddy with the layer-4 (TCP/UDP) app so it can proxy raw mail ports,
# not just HTTP. caddy-ratelimit is included to match the house build on `box`.
#
# docker build -t tailwart-caddy ./caddy
#
# Pinned to 2.11 to match box's Caddy. Bump deliberately.
FROM caddy:2.11-builder AS build
RUN xcaddy build \
--with github.com/mholt/caddy-l4 \
--with github.com/mholt/caddy-ratelimit
FROM caddy:2.11
COPY --from=build /usr/bin/caddy /usr/bin/caddy
# Proof the L4 module is in the binary (fails the build if not):
RUN caddy list-modules | grep -q 'layer4' || (echo 'caddy-l4 missing!' && exit 1)

49
caddy/README.md Normal file
View File

@ -0,0 +1,49 @@
# tailwart edge — layer-4 mail proxy
A custom Caddy (with the `caddy-l4` app) that pipes the public mail ports to the
Stalwart sidecar over the tailnet. Pure TCP pass-through with PROXY protocol —
Stalwart still terminates all the TLS. **Runs anywhere** with a public IP that's
on the tailnet and tagged `tag:reverse-proxy`; doesn't need to share a host with
the mailbox.
## Why layer 4 and not a normal Caddy vhost
Web apps reverse-proxy at layer 7 (route by Host/SNI, Caddy terminates TLS).
Mail can't: port 25 has no SNI (STARTTLS comes after connect), and you want one
global `:25` listener, not per-domain routing. So the edge is a dumb L4 pipe and
Stalwart owns the TLS. The novelty you spotted: this is the same `stream`-style
proxying nginx/Caddy can do for *any* TCP — it just usually isn't used for it.
## Build & run
```bash
docker compose up -d --build # builds Dockerfile (xcaddy + caddy-l4), runs it
caddy list-modules | grep layer4 # (inside the image) proof the module loaded
```
The build fails loudly if `caddy-l4` isn't in the resulting binary.
## Edit the upstream
`caddy.json` dials `stalwart.tail7b1641.ts.net:<port>`. If your
`STALWART_MAGIC_NAME` / `TS_TAILNET` differ, update the five `dial` lines. (JSON
can't read `.env`; this is the one spot the MagicDNS name is hardcoded — same
trade-off as pgAdmin's `servers.json`.)
## The HTTP side (JMAP / autoconfig / admin) is separate
That part *is* ordinary layer 7. Don't put it here if this box already runs the
main Caddy on :443 — you'll collide. Instead add a vhost to the existing Caddy:
```caddyfile
mail.infinidim.net {
reverse_proxy stalwart.tail7b1641.ts.net:8080
}
```
## Prerequisites on the host running this
- Joined to the tailnet, tagged `tag:reverse-proxy` (so the ACL lets it reach
`tag:stalwart`).
- Public firewall opens for whichever mail ports you expose (`25` minimum).
- Nothing else bound to those ports.

52
caddy/caddy.json Normal file
View File

@ -0,0 +1,52 @@
{
"//": "Layer-4 mail edge for tailwart. Pipes raw TCP mail ports to the",
"//2": "Stalwart sidecar over the tailnet, preserving client IP via PROXY",
"//3": "protocol v2. One server per port (upstream port differs per listener).",
"//4": "Stalwart terminates TLS itself — this is pure pass-through.",
"apps": {
"layer4": {
"servers": {
"smtp": {
"listen": [":25"],
"routes": [{ "handle": [{
"handler": "proxy",
"proxy_protocol": "v2",
"upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:25"] }]
}]}]
},
"submissions": {
"listen": [":465"],
"routes": [{ "handle": [{
"handler": "proxy",
"proxy_protocol": "v2",
"upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:465"] }]
}]}]
},
"submission": {
"listen": [":587"],
"routes": [{ "handle": [{
"handler": "proxy",
"proxy_protocol": "v2",
"upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:587"] }]
}]}]
},
"imap": {
"listen": [":143"],
"routes": [{ "handle": [{
"handler": "proxy",
"proxy_protocol": "v2",
"upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:143"] }]
}]}]
},
"imaptls": {
"listen": [":993"],
"routes": [{ "handle": [{
"handler": "proxy",
"proxy_protocol": "v2",
"upstreams": [{ "dial": ["stalwart.tail7b1641.ts.net:993"] }]
}]}]
}
}
}
}
}

26
caddy/docker-compose.yml Normal file
View File

@ -0,0 +1,26 @@
# tailwart edge — the layer-4 mail proxy. Deploy this on ANY host with a public
# IP that is joined to the tailnet and tagged tag:reverse-proxy. It does not
# have to be the same machine as the Stalwart mailbox — that's the fun part.
#
# docker compose up -d --build
#
# network_mode: host is deliberate: the proxy needs to bind the public mail
# ports AND resolve/reach ${STALWART_MAGIC_NAME}.${TS_TAILNET} via the host's
# tailscaled (MagicDNS). The mailbox container, by contrast, binds nothing on
# the host — opposite ends of the same wire.
name: tailwart-edge
services:
caddy-l4:
build: .
image: tailwart-caddy:latest
network_mode: host
command: ["caddy", "run", "--config", "/etc/caddy/caddy.json"]
volumes:
- ./caddy.json:/etc/caddy/caddy.json:ro
- caddy-data:/data
restart: unless-stopped
volumes:
caddy-data:

108
config/config.toml Normal file
View File

@ -0,0 +1,108 @@
# ============================================================================
# Stalwart config — the over-engineered wiring: Postgres + Redis + Garage S3.
# ============================================================================
# THIS IS A STRAWMAN. Stalwart's config schema shifts between releases and a
# lot of runtime config moves into the admin store after first boot. Treat
# every key here as "verify against the version you pinned" — the same pitfall
# the federatedSocial repo learned the hard way. Pin the image tag once it works.
#
# Secrets come in via %{env:NAME}% so this file stays commit-safe.
# ============================================================================
[server]
hostname = "%{env:STALWART_HOSTNAME}%"
# ----------------------------------------------------------------------------
# Listeners — all bind inside the sidecar netns (tailnet only). The public
# edge is caddy/ (layer 4). PROXY protocol carries the real client IP across
# the proxy hop, which mail reputation (SPF/DNSBL/greylisting) depends on.
# ----------------------------------------------------------------------------
[server.listener.smtp]
bind = ["[::]:25"]
protocol = "smtp"
[server.listener.submission]
bind = ["[::]:587"]
protocol = "smtp"
[server.listener.submissions]
bind = ["[::]:465"]
protocol = "smtp"
tls.implicit = true
[server.listener.imap]
bind = ["[::]:143"]
protocol = "imap"
[server.listener.imaptls]
bind = ["[::]:993"]
protocol = "imap"
tls.implicit = true
[server.listener.http]
# JMAP, autoconfig/autodiscover, and the web admin. Fronted by the existing
# layer-7 Caddy vhost (mail.infinidim.net), not the L4 proxy.
bind = ["[::]:8080"]
protocol = "http"
# Trust the PROXY-protocol header only from the tailnet hop (CGNAT range).
[server.proxy]
trusted-networks = ["100.64.0.0/10"]
# ----------------------------------------------------------------------------
# Stores — three backends because we can.
# ----------------------------------------------------------------------------
[store.pg]
type = "postgresql"
host = "%{env:DB_HOST}%"
port = 5432
database = "%{env:STALWART_DB_NAME}%"
user = "%{env:STALWART_DB_USER}%"
password = "%{env:STALWART_DB_PASSWORD}%"
[store.redis]
type = "redis"
urls = ["%{env:REDIS_URL}%"]
[store.s3]
type = "s3"
endpoint = "%{env:S3_ENDPOINT}%"
region = "%{env:S3_REGION}%"
bucket = "%{env:S3_BUCKET}%"
access-key = "%{env:S3_ACCESS_KEY}%"
secret-key = "%{env:S3_SECRET_KEY}%"
# Which store backs which concern.
[storage]
data = "pg" # accounts, mailboxes, metadata, indexes
fts = "pg" # full-text search
blob = "s3" # message bodies + attachments → Garage
lookup = "redis" # rate limits, caches, token revocation (distributed)
directory = "internal"
[directory.internal]
type = "internal"
store = "pg"
# ----------------------------------------------------------------------------
# Outbound — relay via smarthost when the VPS blocks direct :25.
# ----------------------------------------------------------------------------
[queue.outbound]
# If STALWART_SMARTHOST is set, route everything through it; else direct MX.
next-hop = "%{env:STALWART_SMARTHOST}%"
# ----------------------------------------------------------------------------
# TLS — Stalwart terminates mail TLS itself (the L4 proxy is pass-through),
# so it needs the cert. DNS-01 works fine behind the proxy (no inbound needed).
# Swap to a mounted certbot cert by pointing [certificate] at files instead.
# ----------------------------------------------------------------------------
[acme.le]
directory = "https://acme-v02.api.letsencrypt.org/directory"
challenge = "dns-01"
provider = "%{env:STALWART_ACME_PROVIDER}%"
secret = "%{env:STALWART_ACME_TOKEN}%"
domains = ["%{env:STALWART_HOSTNAME}%"]
[authentication.fallback-admin]
user = "admin"
secret = "%{env:STALWART_FALLBACK_ADMIN_SECRET}%"

72
docker-compose.yml Normal file
View File

@ -0,0 +1,72 @@
# tailwart — Stalwart mailbox as a Tailscale sidecar (NO WAN presence).
#
# The container shares ts-stalwart's network namespace, so its only interfaces
# are lo and tailscale0. All mail ports listen on the tailnet only; the public
# edge is the separate caddy/ layer-4 proxy, which can run on another host.
#
# Prereq: the shared tailnet infra (Postgres the-record-prod, Redis
# slo-time-prod, Garage) must be up, and the stalwart role/db/bucket created
# (see README). Bring up: docker compose up -d
name: tailwart
services:
ts-stalwart:
image: tailscale/tailscale:latest
hostname: ${STALWART_MAGIC_NAME}
environment:
TS_AUTHKEY: ${TS_OAUTH_CLIENT_SECRET}?ephemeral=true
TS_EXTRA_ARGS: --advertise-tags=tag:stalwart
TS_HOSTNAME: ${STALWART_MAGIC_NAME}
TS_ACCEPT_DNS: "true"
TS_AUTH_ONCE: "true"
TS_USERSPACE: "false"
TS_ENABLE_HEALTH_CHECK: "true"
TS_LOCAL_ADDR_PORT: "127.0.0.1:9002"
dns: [1.1.1.1, 1.0.0.1]
devices:
- /dev/net/tun:/dev/net/tun
cap_add:
- NET_ADMIN
- NET_RAW
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:9002/healthz"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
restart: unless-stopped
stalwart:
image: stalwartlabs/mail-server:latest
network_mode: "service:ts-stalwart"
environment:
# Consumed by config/config.toml via its %{env:NAME}% macros. Keeping
# secrets in env (not the mounted toml) means the toml is commit-safe.
STALWART_DB_NAME: ${STALWART_DB_NAME}
STALWART_DB_USER: ${STALWART_DB_USER}
STALWART_DB_PASSWORD: ${STALWART_DB_PASSWORD}
DB_HOST: ${DB_MAGIC_NAME}.${TS_TAILNET}
REDIS_URL: redis://${REDIS_MAGIC_NAME}.${TS_TAILNET}:6379/${STALWART_REDIS_DB}
S3_ENDPOINT: http://${GARAGE_MAGIC_NAME}.${TS_TAILNET}:3900
S3_REGION: ${GARAGE_REGION}
S3_BUCKET: ${STALWART_S3_BUCKET}
S3_ACCESS_KEY: ${GARAGE_ACCESS_KEY_ID}
S3_SECRET_KEY: ${GARAGE_SECRET_ACCESS_KEY}
STALWART_HOSTNAME: ${STALWART_HOSTNAME}
STALWART_DOMAIN: ${STALWART_DOMAIN}
STALWART_SMARTHOST: ${STALWART_SMARTHOST}
STALWART_FALLBACK_ADMIN_SECRET: ${STALWART_FALLBACK_ADMIN_SECRET}
volumes:
- ./config/config.toml:/opt/stalwart-mail/etc/config.toml:ro
# Local working dir only (logs, ACME cache, queue spool). The bulk data
# lives in Postgres + Garage, not here — but Stalwart still wants a home.
- stalwart-data:/opt/stalwart-mail
depends_on:
ts-stalwart:
condition: service_healthy
restart: unless-stopped
volumes:
stalwart-data: