tailwart/docker-compose.yml
Wayne Hayes 34422ba2b1 mailbox: give sidecar netns real IPv6 egress; resolve AAAA trap; DNS notes
Add enable_ipv6 + a ULA subnet to tailwart_default so the Stalwart
container (sharing the ts-stalwart netns) gets working IPv6 egress.
Because only egress is needed (inbound arrives via the edge/tailnet),
a ULA + Docker masquerade suffices -- no routable prefix, ndppd, or
host sysctl changes (Docker 29 enables ip6tables by default; host
forwarding was already on). Verified: ping6 + TCP/443 to v6 literals
from inside the netns; zero ENETUNREACH since boot.

LESSONS: mark #8/#9 resolved with the ULA-masquerade recipe, and add
#13 -- Spaceship's DNS API is RRSet-upsert (not zone-replace), so
Stalwart/ACME did not eat custom AAAA records; a vanished AAAA is a
provider-side loss, not Stalwart. Includes the safe read/verify flow
and the "don't publish mail AAAA before edge v6 listeners" caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:53:28 +01:00

99 lines
4.1 KiB
YAML

# 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:
# Healthy only when BOTH the tailnet link is up AND Postgres is reachable
# over it. The stalwart service gates on this (depends_on: service_healthy),
# so it can no longer start into the race where it tries the DB before the
# tailnet route exists — which logged "Failed to create tables" and dropped
# in-flight cert/setting writes (e.g. lost the ACME cert on 2026-06-10).
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:9002/healthz && nc -z -w3 ${DB_MAGIC_NAME}.${TS_TAILNET} 5432"]
interval: 10s
timeout: 8s
retries: 6
start_period: 30s
restart: unless-stopped
stalwart:
image: stalwartlabs/stalwart:v0.16.7
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:
# Bootstrap config (v0.16 JSON): tells Stalwart only where Postgres lives;
# all other settings live in the DB. Mounted at the image's default
# --config path (/etc/stalwart/config.json). Secret comes from the
# STALWART_DB_PASSWORD env above, referenced via the EnvironmentVariable
# secret type inside the file — so this stays commit-safe.
- ./config/config.json:/etc/stalwart/config.json:ro
# Working dir: ACME cert cache + outbound queue spool. Named volume (not
# anonymous) so a recreate doesn't orphan it and drop queued mail/certs.
- stalwart-data:/var/lib/stalwart
depends_on:
ts-stalwart:
condition: service_healthy
restart: unless-stopped
volumes:
stalwart-data:
# The sidecar's bridge (shared by stalwart via network_mode) gets IPv6 here so
# the container can reach AAAA-only / dual-stack hosts. Without it the netns has
# no global v6 → Stalwart tries AAAA first, gets ENETUNREACH, and for a relay
# next-hop never falls back to A (see LESSONS.md #8). A ULA subnet is fine: we
# only need *egress* (inbound arrives via the edge/tailnet, never v6). Docker 29
# masquerades it out the host's global v6 via ip6tables — no routable prefix,
# NDP proxy, or host sysctl needed. Recreating this network bounces the stack
# and the ephemeral sidecar gets a new tailnet IP (MagicDNS handles it).
networks:
default:
enable_ipv6: true
ipam:
config:
- subnet: fd00:7a17:600d::/64
gateway: fd00:7a17:600d::1