Skip to Content

Production Guide: Deploy Stalwart Mail Server with Docker Compose + Caddy on Ubuntu

Self-host a modern SMTP/IMAP/JMAP mail server with built-in DKIM signing, spam filtering, and a web admin console

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 compose command available).
  • Caddy installed on the host as a systemd service (apt install caddy or 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 reload

2. Create the project directory and data volume

mkdir -p /opt/stalwart/data
cd /opt/stalwart

3. 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/.env

Replace 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=UTC

5. 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 caddy

6. Start Stalwart

cd /opt/stalwart
docker compose up -d
docker compose logs -f stalwart

On 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.toml under 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_threshold score 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 stalwart

Send 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 stalwart

How do I update Stalwart to a new version?

cd /opt/stalwart
docker compose pull
docker compose up -d --remove-orphans
docker compose logs -f stalwart

Stalwart 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

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.

Contact Us

Production Guide: Deploy Casdoor with Docker Compose + Caddy + PostgreSQL on Ubuntu
Self-host your own SSO identity platform with full OIDC/OAuth2, social logins, and MFA — no Okta required.