Mail Server (Stalwart)
larakube mail:init deploys Stalwart β an all-in-one, Rust-based mail
server (SMTP, IMAP, JMAP, POP3, plus CalDAV/CardDAV) β into the larakube-shared namespace.
larakube mail:wire then points your other tools (n8n, Vaultwardenβ¦) at it so their
invite and notification emails are sent through your own mail server.
How it differs from the other shared toolsβ
- Self-contained storage. Unlike most shared tools, Stalwart does not use Plex Commons. It ships with an embedded store on its own PersistentVolumeClaim, so there is no tenant database to allocate and nothing to back up in Postgres β Stalwart owns its own data.
- It's a StatefulSet, not a Deployment. A mail server has durable identity and storage
(mailboxes, config, DKIM keys), so it runs as a
StatefulSet(stalwart-0) with avolumeClaimTemplate, rather than as an interchangeable stateless pod. - Two network paths. The admin/JMAP web UI is fronted by Traefik at
mail.<domain>(TLS terminated at the ingress). The mail protocol ports β 25, 465, 587, 993, 4190 β are exposed by aLoadBalancerService; on single-node k3s the built-in ServiceLB (klipper) binds them straight to the node's IP, so there is no paid cloud load balancer and no clash with Traefik's 80/443. - Firewall ports are opened automatically (cloud VPS). Klipper binds the mail ports on the
node, but two firewalls still default-deny them: the DigitalOcean cloud firewall and the host
UFW. On a cloud deploy,
mail:initopens all five (25/465/587/993/4190) on both layers β a dedicatedlarakube-mail-fw-<droplet>cloud firewall (so your Terraform-managed base firewall is never touched) plus UFW over SSH β and--removecloses them again. On a managed cluster (DOKS) theLoadBalancerService provisions a real cloud load balancer that exposes the ports itself, so no firewall poking is needed.
Stalwart's web UI is for administration and account self-service β it is not a webmail client.
Your team can read and send mail with a standard IMAP/JMAP client (Apple Mail, Thunderbird, the Gmail
app, etc.), or with a browser at larakube webmail:init, which deploys the
Bulwark JMAP webmail UI on top of Stalwart.
larakube mail:initβ
larakube mail:init [environment] [options]
| Option | Description |
|---|---|
environment | local (default) or a cloud env such as production. |
--domain= | Raw domain override β mail.<domain> (skips the host prompt). |
--context= | Target a specific kube-context. |
--vpn-only | Restrict the admin UI behind NetBird VPN IP whitelisting. |
--remove | Tear down the Stalwart stack (StatefulSet, Services, ingress, PVC, secret). |
On success it prints the admin console URL, the generated admin password, and the setup runbook
below.
larakube mail:checkβ
Rather than eyeball the runbook below, run the health-check any time to see exactly what's done and what's left:
larakube mail:check [environment]
It verifies the pod is running, the admin console is reachable, all five mail ports are reachable from your machine, and the DNS/deliverability records (A, MX, SPF, DMARC) β querying a public resolver (1.1.1.1) so a stale local cache can't give a false result β and prints a one-line fix hint for anything that isn't green. Exit code is non-zero while any check fails, so it also works in CI. (DKIM's selector is per-domain, so it points you at the admin UI rather than guessing.)
Post-install runbookβ
Stalwart boots into a first-run setup wizard β a few steps in the web UI finish the install.
Prefer larakube mail:check over ticking these off by hand.
On Stalwart v0.16 the wizard writes its answers into Stalwart's own store (there is no config file to
pre-seed), so the install is a one-time manual step per environment.
The wizard is reliable on a clean store but loops if it finds half-written state from a previous
attempt (An item with the same key already exists). To avoid that:
- Start clean β
larakube mail:init <env> --removewipes the PVC, thenlarakube mail:init <env>. - Complete the wizard in one pass β do not press Back or refresh mid-way.
- On the final "Setup complete" screen, restart Stalwart once so it loads the config from its
store:
larakube mail:restart <env>. After it restarts,/adminshows a normal login instead of the wizard. (The config lives in Stalwart's store, not a file, so a restart is required β skipping it leaves/adminlooping back to the wizard.)
larakube mail:restart)Stalwart reads its configuration store (PostgreSQL/RocksDB) into memory at boot time. When you make changes in the Stalwart Web UI or wizard:
- You must click Save in the bottom-right corner of the Web UI to commit the changes to PostgreSQL.
- You must run
larakube mail:restart <env>so Stalwart's pod reloads the updated store configuration into memory.
1. Complete the setup wizard & configure Plex Commons storesβ
Open https://mail.<domain> and follow the setup wizard. You can configure Stalwart to use your Plex Commons infrastructure for database and blob storage:
Data (Main) Store & Search Store (PostgreSQL)β
- Host:
postgres.larakube-plex.svc.cluster.local - Port:
5432 - Database:
stalwart(auto-created) - Username:
stalwart - Password: Select Secret read from environment variable and enter
STALWART_STORE_PASSWORD.
Blob Store (SeaweedFS S3)β
- Store Type:
S3-compatible - Region: Select
Customin the dropdown (this dynamically reveals the URL input box) - URL:
http://seaweedfs.larakube-plex.svc.cluster.local:8333 - Region Name:
us-east-1 - Bucket:
stalwart - Access Key:
larakube(or select env varSTALWART_S3_KEY_ID) - Secret Key: Select env var
STALWART_S3_SECRET_KEY(or literal key fromlarakube mail:show)
Cache Store (Valkey / Redis)β
- Redis URL:
redis://redis.larakube-plex.svc.cluster.local:6379/0
On the final "Setup complete" screen, click Save, then apply the changes with:
larakube mail:restart <env>
After restarting, /admin presents the standard login screen. Sign in with admin and the password mail:init printed.
2. Valid TLS on the mail ports (DNS-01)β
The ingress covers the web UI, but the mail ports (465/993) are not behind Traefik, so Apple Mail/Thunderbird would hit Stalwart's self-signed cert and warn. Give Stalwart its own certificate via a DNS-01 ACME provider (no port conflict with Traefik):
In Settings β Server β TLS β ACME Providers β Create:
- Challenge =
DNS-01 - DNS provider =
Cloudflare(configure the DNS provider under Settings β Network β DNS with an API token that can edit the zone) - Subject names =
mail.<domain>
Click Save, then run larakube mail:restart <env> to issue and load the certificates.
3. Add your domain + DKIMβ
In Directory β Domains, add <domain>. Open its DKIM tab and copy the generated selector
record β you'll add it to DNS in step 5.
4. Create accountsβ
In Directory β Accounts:
- one account per workmate (this issues their email address + login);
- a dedicated
noreply@<domain>account with an application password β this is whatmail:wireuses.
5. DNS recordsβ
Add these for <domain> (the A record for mail.<domain> is created automatically by ExternalDNS):
| Type | Name | Value |
|---|---|---|
MX | <domain> | mail.<domain> (priority 10) |
TXT | <domain> | v=spf1 mx ~all |
TXT | (DKIM selector) | paste from step 3 |
TXT | _dmarc.<domain> | v=DMARC1; p=quarantine; rua=mailto:postmaster@<domain> |
PTR | reverse DNS on the droplet | mail.<domain> (set at your provider) |
6. Configure clientsβ
| Setting | Value |
|---|---|
| IMAP | mail.<domain> Β· port 993 Β· SSL/TLS |
| SMTP | mail.<domain> Β· port 465 Β· SSL/TLS |
| Username | the full email address |
| Password | the account (or application) password |
Stalwart's default setup binds submission on 465 (implicit TLS), so a 587 test shows
Connection refused (the port is reachable but nothing listens on it). Use 465 / SSL in your
client. In Apple Mail, set the User Name to the full email address β leaving it "Automatic"
sends only the local part and fails with "unable to verify account name or password".
Multiple domains (larakube mail:domain)β
A single Stalwart instance is multi-domain. You can onboard additional domains with fully automatic DNS, TLS, and DKIM management:
larakube mail:domain production --zone=partner.example --cloudflare-token=<token>
- Cloudflare API Token Scope: The partner provides an API token with
ZoneΒ·DNSΒ·Editpermissions for their specific domain. - Stalwart Automation: Registers an
x:DnsServerin Stalwart, provisions a Let's Encrypt TLS certificate via DNS-01, configures single RSA-only DKIM, and creates autoconfig / autodiscover / SRV / MTA-STS records directly on Cloudflare.
Inbound MX & Cloudflare Email Routingβ
- Inbound Delivery: Ensure the domain's root MX record is pointed to your Stalwart server (
send.luchtech.devormail.<domain>) with priority10. - Email Routing Conflict: If the partner previously configured Cloudflare Email Routing / email forwarding, make sure it is disabled or does not override the primary MX records.
Outbound Relay DNS (larakube mail:dns)β
When outbound mail is relayed through Amazon SES (or Brevo), relay domain authentication is required to pass DMARC and prevent delivery failures (bounces to Gmail):
larakube mail:dns production --zone=partner.example --provider=ses \
--ses-tokens=token1,token2,token3 \
--mail-from=bounce
This automatically:
- Auto-discovers the partner's Cloudflare token from Stalwart's
x:DnsServer. - Publishes the 3 AWS SES Easy DKIM CNAME records (
<token>._domainkey.partner.exampleβ<token>.dkim.amazonses.com). - Updates the root SPF TXT record to include Amazon SES:
v=spf1 mx include:amazonses.com ~all. - Publishes Custom MAIL FROM DNS records (
bounce.partner.exampleMX + SPF) to guarantee 100% SPF/DKIM alignment under strict DMARC policies (p=reject/p=quarantine).
MX records are domain-wide. If a domain already delivers mail elsewhere (e.g. Namecheap PrivateEmail, Google Workspace), you cannot split some addresses to that provider and others to Stalwart via MX. Use a separate domain (or subdomain) for Stalwart to avoid disrupting an existing mailbox.
larakube mail:wireβ
Wires a tool's outbound email through Stalwart, with no per-tool glue code.
larakube mail:wire [tool] [--all] [--env=production]
- With no
toolargument it shows a menu of installed, SMTP-capable tools. --allwires every installed SMTP-capable tool at once.- On first run it prompts for the sender (
noreply@<domain>) and its Stalwart application password, then caches them (in amail-sendersecret) so later wires don't re-prompt.
It writes a per-tool secret whose keys are the tool's own SMTP env var names, sets them on the tool's
Deployment (kubectl set env --from=secret), and restarts it.
Supported toolsβ
Any tool becomes wireable by declaring a smtpEnv() schema (deployment, namespace, secret, and
env-var map). Shipped today:
| Tool | Namespace | Env vars |
|---|---|---|
Teable (sheets) | larakube-shared | BACKEND_MAIL_* |
n8n (flow) | larakube-shared | N8N_SMTP_* + N8N_EMAIL_MODE=smtp |
Vaultwarden (passwords) | larakube-vault | SMTP_* |
Both your mail clients and mail:wire send through mail.<domain>:465 (implicit TLS / SSL) β
Stalwart's default submission port, and the modern-recommended one (RFC 8314). So it all works out of
the box with no extra listener. (Stalwart's default setup does not bind 587/STARTTLS; only add a
587 listener if a specific legacy client requires it.)
When you install an SMTP-capable tool with larakube tool:add and Stalwart is present, it offers to
wire the tool right away.
Certificate timingβ
On a brand-new host, Traefik may attempt Let's Encrypt (HTTP-01) a moment before ExternalDNS has
created the DNS record, logging NXDOMAIN looking up A for mail.<domain> and temporarily serving its
fallback cert. Because .dev/.app are HSTS-preloaded, the browser won't let you click through.
It resolves on Traefik's next retry once DNS has propagated (restarting the Traefik pod forces an
immediate retry). Once you configure the DNS-01 provider in step 2, Stalwart renews the mail-port
certificate independently.
Whenever you tear down and redeploy, ExternalDNS re-creates the A record β but your machine may
still have the old NXDOMAIN negatively cached, so the browser shows ERR_NAME_NOT_RESOLVED even
though the server is fine. This isn't mail-specific β it applies to any ExternalDNS-managed host you've
just recreated. Confirm the server is actually up (dig mail.<domain> returns the node IP), then flush:
# macOS
sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
# Chrome also caches separately: chrome://net-internals/#dns β Clear host cache
Or just open it in an Incognito window to test immediately.
Deliverabilityβ
Three separate things gate real-world mail; internal mail (teammate β teammate on the same domain) works without any of them.
- Inbound needs MX. External mail only reaches you once
<domain>'s MX points atmail.<domain>(step 5). - Outbound needs a relay on DigitalOcean. DigitalOcean (like most clouds) blocks outbound
port 25 by default, so Stalwart cannot deliver directly to recipients' servers β mail to Gmail
and other external addresses will queue but never deliver. Configure an outbound relay with
larakube mail:relay(Brevo today; it drives Stalwart's API to add the relay route + point outbound at it, and prints the provider's SPF/DKIM steps). A relay also solves cold-IP reputation, so it's the right choice even where 25 is open.--removereverts to direct MX. - Reputation. Set PTR/rDNS and publish SPF/DKIM/DMARC (steps 3 & 5).
larakube mail:testlarakube mail:test [email protected] --env=production authenticates as a Stalwart account and submits a message
(from inside the pod, so your own network can't block it). It reports whether AUTH succeeded and
whether the message was accepted, and β for an external recipient β warns if no relay is configured
(so a "queued" result isn't mistaken for "delivered"). Send to a local @<domain> address to confirm
the internal path with no relay; send to your Gmail to confirm the relay once it's set up.
The Emails β History (inbound/outbound delivery log) is a Stalwart Enterprise feature and shows a padlock on the community edition β it does not affect mail flow. To verify delivery on community: use Emails β Delivery tests (unlocked) for outbound tracing, Emails β Queued for in-flight messages, and a connected IMAP client's inbox to confirm receipt.