Running your own email server has historically meant wrestling with Postfix configuration files, Dovecot quirks, and a pile of separate daemons held together with cron jobs and custom scripts. Stalwart Mail Server is a modern open-source alternative written in Rust that bundles SMTP, IMAP4, JMAP, and admin API into a single binary. It handles full message delivery, spam filtering hooks, DKIM signing, and TLS — without requiring you to assemble five different packages and debug their interactions. For teams managing internal infrastructure or small organizations that need reliable email without a Google Workspace subscription, Stalwart offers a production-worthy foundation that fits comfortably in a single Docker container.
This guide walks through a complete Stalwart Mail Server deployment on Ubuntu using Docker Compose and Caddy as the TLS reverse proxy for the webadmin interface. You will finish with a fully configured mail server that handles inbound and outbound email, passes DKIM/SPF/DMARC checks, and exposes a secure admin console through Caddy's automatic HTTPS.
Architecture and flow overview
Stalwart runs as a single stateful container. It speaks SMTP (port 25 for inbound MX delivery, port 587 for authenticated submission), IMAP4 (port 993), and exposes an HTTP admin console and JMAP endpoint. All configuration, accounts, and message data live on a bind-mounted volume on the host so the container is ephemeral and upgrades are safe.
In this stack:
- Stalwart container — the all-in-one mail server. Ports 25, 587, and 993 are published directly on the host for mail protocol traffic. The admin HTTP endpoint is exposed only on
127.0.0.1:8080. - Caddy — terminates TLS for the webadmin interface at your domain (e.g.,
mail-admin.example.com), automatically provisions a Let's Encrypt certificate, and reverse-proxies to the Stalwart HTTP API. - Docker Compose — manages the Stalwart container lifecycle, volume mounts, restart policy, and network isolation. Caddy runs as a host-level service so it can bind ports 80 and 443 without competing with the mail ports.
Inbound email arrives on port 25 from sending mail servers via MX record resolution. Authenticated clients (your mail client apps) connect on port 587 with STARTTLS for submission and port 993 for IMAP with implicit TLS. Stalwart handles TLS directly for mail protocol ports using a certificate you supply — separate from Caddy's certificate, because mail clients connect directly to Stalwart, not through a proxy. Caddy only fronts the admin web console.
Prerequisites
- Ubuntu 22.04 or 24.04 server with a dedicated public IP address.
- Docker Engine 24+ and Docker Compose v2 installed (
docker composecommand available). - Caddy installed on the host as a systemd service (
apt install caddyor the official Caddy repo package). - A domain name with DNS control. You will need to create A records, MX records, SPF/DKIM/DMARC TXT records, and a PTR (reverse DNS) record on your IP.
- Ports 25, 80, 443, 587, and 993 open in UFW and any upstream firewall/cloud security group.
- Root or sudo access. At least 1 GB RAM (2 GB recommended for active mail queues).
- A PTR (reverse DNS) record for your server IP pointing to your mail hostname — required by most receiving mail servers to accept your outbound mail. Set this in your cloud/VPS provider's control panel.
Step-by-step deployment
1. Open required firewall ports
ufw allow 25/tcp comment "SMTP MX inbound"
ufw allow 587/tcp comment "SMTP submission"
ufw allow 993/tcp comment "IMAP TLS"
ufw allow 80/tcp comment "HTTP (Caddy ACME)"
ufw allow 443/tcp comment "HTTPS (admin)"
ufw reload2. Create the project directory and data volume
mkdir -p /opt/stalwart/data
cd /opt/stalwart3. Create the environment file
Store credentials and domain configuration in a .env file and restrict its permissions immediately.
cat > /opt/stalwart/.env << 'EOF'
STALWART_DOMAIN=mail.example.com
STALWART_ADMIN_DOMAIN=mail-admin.example.com
STALWART_ADMIN_SECRET=change_this_strong_secret
EOF
chmod 600 /opt/stalwart/.envReplace mail.example.com with your actual mail hostname and set a strong admin secret.
4. Write the Docker Compose file
version: "3.8"
services:
stalwart:
image: stalwartlabs/mail-server:latest
container_name: stalwart
restart: unless-stopped
env_file: .env
ports:
- "25:25"
- "587:587"
- "993:993"
- "127.0.0.1:8080:8080"
volumes:
- ./data:/opt/stalwart-mail
environment:
- TZ=UTC5. Configure Caddy for the admin interface
Add a site block to your Caddyfile (typically at /etc/caddy/Caddyfile). Caddy will provision a Let's Encrypt certificate automatically.
mail-admin.example.com {
reverse_proxy 127.0.0.1:8080
encode gzip
log {
output file /var/log/caddy/stalwart-access.log
}
}Replace mail-admin.example.com with your actual admin subdomain. Reload Caddy after saving.
systemctl reload caddy6. Start Stalwart
cd /opt/stalwart
docker compose up -d
docker compose logs -f stalwartOn first boot Stalwart initializes its data directory and generates a default configuration file at /opt/stalwart/data/etc/config.toml. Look for a log line like Server is ready to accept connections before proceeding.
7. Complete initial setup via admin UI
Navigate to https://mail-admin.example.com in your browser. Stalwart will present a first-run wizard. Enter your chosen admin password and follow the prompts to set your primary domain and generate initial DKIM keys. The wizard writes the generated DKIM DNS record values to the admin panel — copy these before leaving the page.
Configuration and secrets handling
The primary configuration file lives at /opt/stalwart/data/etc/config.toml. Most settings are also editable through the web admin panel, which writes changes back to this file. For infrastructure-as-code environments, make this file part of your backup strategy — it encodes your domain settings, TLS paths, DKIM private keys, and listener config.
Critical configuration items to set after first boot:
- TLS certificates for mail ports — Stalwart manages its own TLS for SMTP and IMAP. Either use Let's Encrypt via its built-in ACME client, or supply an existing certificate PEM. Do not rely on Caddy for mail protocol TLS since Caddy does not proxy raw TCP to Stalwart's mail ports.
- DKIM private key — generated automatically during setup and stored in
/opt/stalwart/data/etc/dkim/. Back this directory up separately. Losing the DKIM key means all outbound mail fails DKIM validation until DNS records are rotated. - Relay host (optional) — if your VPS provider blocks outbound port 25 (common with AWS, GCP, Azure), configure a transactional relay (Postmark, Mailgun, or Amazon SES) in
config.tomlunder the[queue.outbound.relay]section. This routes all outbound mail through the relay's authenticated SMTP port 587. - Spam filtering — Stalwart ships with built-in Sieve-based filtering. Review the default spam scoring thresholds under Settings → Spam Filter in the admin panel. Lower the
spam_thresholdscore if you see too many false positives in the initial weeks.
Store the admin secret and any relay API keys in environment variables — never hardcode them in config.toml. Stalwart supports environment variable substitution in its config file using the ${ENV_VAR} syntax.
Verification
After setup, verify every layer before declaring the server production-ready:
# Check containers are running
docker compose ps
# Verify SMTP port 25 is accepting connections
nc -zv 0.0.0.0 25
# Verify IMAP port 993
nc -zv 0.0.0.0 993
# Check Caddy proxies the admin interface
curl -I https://mail-admin.example.com
# Tail Stalwart logs for errors
docker compose logs --tail=50 stalwartSend a test email from an external account (Gmail, Outlook) to an address at your new domain. Log in with your IMAP client using mail.example.com port 993, implicit TLS, and confirm the message arrives. Then send outbound from your Stalwart account to an external address. Check the email headers in the recipient's inbox — look for dkim=pass and spf=pass in the Authentication-Results header.
Use MXToolbox Email Health to verify MX records, SPF, DKIM, DMARC, and PTR all resolve correctly for your domain.
Common issues and fixes
Outbound mail rejected with "connection timed out" on port 25
Your cloud/VPS provider almost certainly blocks outbound port 25 by default to prevent abuse. File a support ticket to have the block lifted, or configure an SMTP relay service. AWS SES and Postmark both offer free tiers for low-volume senders. Configure the relay in config.toml under [queue.outbound].
DKIM validation fails for outbound messages
Verify the DKIM TXT record is published in your DNS and has propagated (can take up to 48 hours). Use dig TXT mail._domainkey.example.com to confirm the record exists. Also check that the selector name in Stalwart's config matches the selector in the DNS record — a mismatch causes silent DKIM signing with no verification failure at send time, but the receiving server rejects the signature.
Admin panel shows "502 Bad Gateway" through Caddy
Stalwart's HTTP listener is not running or bound to a different address. Check container logs with docker compose logs stalwart. Verify that 127.0.0.1:8080 is bound: ss -tlnp | grep 8080. If Stalwart is healthy but Caddy still returns 502, confirm the Caddyfile site block uses the correct upstream address and run caddy validate --config /etc/caddy/Caddyfile.
IMAP clients cannot connect on port 993
Stalwart must have a valid TLS certificate for the mail ports — separate from the Caddy certificate. If the ACME challenge for mail TLS fails (port 80 blocked, DNS propagation lag), Stalwart falls back to a self-signed certificate which most IMAP clients reject. Use the admin panel under Settings → TLS → ACME to retry the certificate issuance, or provide a manually obtained certificate PEM.
Messages stuck in the outbound queue
Open the admin panel, navigate to Queues → Message Queue, and inspect stuck messages for the error description. Common causes: recipient server greylisting (retry delay — messages will self-resolve), DNS resolution failure for the recipient domain, or TLS negotiation mismatch. Force a queue retry with the Retry button in the admin panel or via the management API.
FAQ
Does Stalwart support multiple domains?
Yes. Stalwart natively supports multiple domains in a single instance. Add additional domains under Settings → Domains in the admin panel. Each domain gets its own DKIM key, MX records, and account namespace. Accounts are addressed as [email protected] or [email protected] independently.
Can I migrate existing mailboxes from another server?
Stalwart supports IMAP-to-IMAP migration using its built-in imapsync-compatible tooling. You can also use the standard imapsync CLI tool pointed at both servers. For Maildir or mbox format migrations, use Stalwart's import CLI: docker exec stalwart stalwart-mail import maildir --path /path/to/maildir --account [email protected]. Always run a test import on a scratch account before migrating production mailboxes.
Is Stalwart production-ready for high-volume sending?
Stalwart is written in async Rust and designed for high throughput with low memory overhead. Single-node Stalwart instances handle tens of thousands of messages per hour comfortably on modest hardware. For very high volumes (hundreds of thousands per day), use a transactional relay for outbound delivery and benchmark your queue configuration. The [queue.outbound.concurrency] setting controls parallel delivery connections.
How do I add new email accounts?
Use the admin panel under Accounts → Add Account. Provide the email address, display name, password, and storage quota. Stalwart supports both local accounts and LDAP/Active Directory authentication — configure directory integration under Settings → Directory to sync accounts from your existing user store automatically.
How do I back up Stalwart?
All persistent state lives under /opt/stalwart/data/. A complete backup is a filesystem snapshot or rsync of that directory. Critical subdirectories: data/etc/ (config and DKIM keys), data/db/ (message store and accounts), and data/blobs/ (large message attachments). Schedule daily backups with a script and ship the archives to object storage.
docker compose stop stalwart
tar -czf /backups/stalwart-$(date +%Y%m%d).tar.gz -C /opt/stalwart data/
docker compose start stalwartHow do I update Stalwart to a new version?
cd /opt/stalwart
docker compose pull
docker compose up -d --remove-orphans
docker compose logs -f stalwartStalwart applies any schema migrations automatically on startup. Review the release notes on the Stalwart GitHub for breaking configuration changes before pulling major version updates on production servers.
Internal links
- Production Guide: Deploy Vaultwarden with Docker Compose + Caddy on Ubuntu
- Production Guide: Deploy Casdoor with Docker Compose + Caddy + PostgreSQL on Ubuntu
- Production Guide: Deploy Authentik with Docker Compose + Caddy + PostgreSQL + Redis on Ubuntu
Talk to us
If you want this deployed with hardened access controls, monitoring standards, and production runbooks tailored to your environment, our team can help end-to-end.