Stalwart is a collaboration server with a self-hostable free tier. it provides not only email, but also calendar, contact, and filesharing support.

See also

Docker installation

For docker compose:

services:
  stalwart:
    container_name: stalwart
    image: stalwartlabs/stalwart:latest
    restart: unless-stopped
    ports:
      - "25:25"    # SMTP
      #- 110:110    # POP3
      #- 443:443    # HTTPS
      - 465:465    # SMTPS
      - 587:587    # SMTP submission (STARTTLS)
      #- 143:143    # IMAP4
      - 993:993    # IMAPS
      #- 995:995    # POP3S
      #- 4190:4190  # ManageSieve
      #- 8080:8080  # HTTPS admin interface
    volumes:
      - ./config:/etc/stalwart
      - /opt/docker/stalwart/data:/var/lib/stalwart
      - /opt/docker/stalwart/logs:/var/log/stalwart
    env_file:
      - stalwart.env
    dns:
      - 9.9.9.9
      - 1.1.1.1

and the accompanying stalwart.env:

STALWART_PUBLIC_URL=https://stalwart.example.com

Make sure you set all the mounts’ to be owned by UID 2000.

Docker Compose notes

  • I’ve commented out the ports that I don’t use here, but listed them anyways for reference.
    • Port 8080 is the admin interface and, if you are able to, should be made available only to trusted networks
    • Port 443 is public-facing HTTPS for web administration, JMAP, OAuth, certificate provisioning, and a few others. If you don’t map it here, you will need to set up a reverse-proxy to it.
  • I had problems with Stalwart being unable to do DNS queries when starting up. I’m not sure why (haven’t had this problem with other similarly-defined compose services on this machine, nor could I find any references to it online), but specifying the dns options was enough to get it running.

Reverse proxy settings

I have two reverse proxies enabled which point at this container for different purposes:

  • stalwart.priv.example.com ⇒ JMAP HTTPS endpoint
  • stalwart-admin.priv.example.com ⇒ Web admin UI

The Caddy configuration for both is as follows. Both of these use the “vpn only” snippet, which only allows access from my VPN subnet; so you must be on this VPN server to access these endpoints:

(vpn-only) {
    @blocked not remote_ip 192.168.37.0/24
    respond @blocked "<h1>Access Denied</h1>" 403
}

stalwart.priv.example.com {
    import vpn-only
    reverse_proxy https://stalwart:443 {
        transport http {
            proxy_protocol v2
            tls_insecure_skip_verify
        }
    }
}

stalwart-admin.priv.example.com {
    import vpn-only
    reverse_proxy stalwart:8080
}

Settings

General notes… the web UI has three tabs (bottom left):

  • Management
  • Configuration
  • Account

Settings seem to be divided up between them according to some rules I can’t quite figure out.

Management section

  • Domains → Domains:
    • Create a domain
      • Name: example.com
      • Enabled: Yes
      • DKIM management: Automatic DKIM management
      • TLS certificate management: ACME TLS certificate management
      • DNS management: Automatic DNS management
        • Record types: DKIM public keys, DMARC policy, MTA-STS policy record, MX records, SPF records, SRV records, TLS reporting record

Configuration section

  • Network → General:
    • Default hostname: example.com
      • This is what you want to appear in your MX record
    • Default domain: as applicable
  • Network → Listeners:
    • Configure as applicable. I use: HTTPS, pp3s, imaps, submissions
  • Network → DNS:
    • DNS Providers:
      • DNS server type: AWS Route53
      • Access Key ID: from IAM
      • Secret Access Key: from IAM
      • Zone: as needed
      • Timeout: 30sec
      • TTL: 5min
      • Polling interval: 15sec
      • Propagation timeout: 5min

Authentication

See Authentik & Stalwart.

Troubleshooting

Stalwart’s docker container doesn’t make very much noise when something goes wrong; the instinctive approach of sudo docker logs stalwart --tail=20 often just returns total silence.

Instead, you can try:

  • Checking the log file in the /opt/docker/stalwart/logs folder on the host (or wherever else you mapped /var/log/stalwart to through docker).
    • This log is colorized by defailt — cat / tail are probably better ways to read it, as your console will reapply the formatting (less or vim will not, and you’ll have to read through escape codes)
  • Clearing caches via admin panel ⇒ Management ⇒ Actions ⇒ Cache ⇒ Invalidate all caches. This seems to wipe out old values stuck (somewhere?).
  • Triggering a config reload via the admin panel ⇒ Management ⇒ Actions ⇒ Reload ⇒ Server Settings. This will sometimes spit out an error message if it fails.
  • Checking failed tasks via the admin panel ⇒ Management ⇒ Tasks ⇒ Failed. Select a failed task, and at the bottom of the page, inspect the Failure Reason.