Skip to content

Stalwart Mail Server and Bulwark Webmail (Docker Compose)

This page walks through a full install of a self-hosted mail server on a fresh VPS:

  • Stalwart is the mail server. It handles SMTP, IMAP, JMAP, calendars, contacts, spam filtering, DKIM, and its own Let's Encrypt certificate.
  • Bulwark is a modern webmail client for Stalwart. It stores no mail. It talks to Stalwart over JMAP.
  • Caddy is a reverse proxy. It owns ports 80 and 443 and routes each web hostname to the right container.

Everything runs as three containers in one Docker Compose stack.

All names and addresses on this page are examples. Replace them with your own:

Example value Replace with
example.com Your mail domain
mail.example.com Your mail server hostname
webmail.example.com Your webmail hostname
203.0.113.10 Your VPS public IPv4 address
[email protected] Your first mailbox

How it fits together

                         Internet
                            |
        +-------------------+--------------------+
        |                                        |
  Mail ports (direct)                     Web ports (Caddy)
  25, 465, 587, 993, 4190                 80, 443
        |                                        |
        v                                        v
   +----------+     http://stalwart:8080    +---------+
   | Stalwart | <-------------------------- |  Caddy  |
   +----------+                             +---------+
        ^                                        |
        |        http://bulwark:3000             |
        |  JMAP  +---------+ <-------------------+
        +--------| Bulwark |
                 +---------+
Hostname What answers Used for
mail.example.com Stalwart (mail ports direct, web through Caddy) SMTP, IMAP, JMAP, admin console at /admin, user self-service at /account
webmail.example.com Bulwark (through Caddy) Webmail in the browser
autoconfig, autodiscover, mta-sts, ua-auto-config Stalwart (through Caddy) Automatic mail client setup and MTA-STS policy

Why a reverse proxy is needed: Stalwart and Bulwark both serve HTTPS, and only one program can listen on port 443. The reverse proxy takes 443 and forwards each hostname to the right container. Because the proxy holds port 443, Stalwart gets its own certificate through the Cloudflare DNS challenge (DNS-01), which needs no open ports.

Choosing a reverse proxy

Any reverse proxy that can forward HTTPS by hostname works here. This guide uses Caddy.

Option Notes
Caddy (used here) One container, a short config file, and automatic Let's Encrypt certificates.
Traefik Configured with Docker labels instead of a config file. A good fit if you already run it.
Nginx or Nginx Proxy Manager Nginx Proxy Manager adds a web UI for managing hosts and certificates.
Cloudflare Tunnel No inbound 80 or 443 needed, but it only carries web traffic, and the Free plan limits each upload to 100 MB (large webmail attachments fail). The mail hostname must stay a normal DNS record because the mail ports cannot go through a tunnel.

The rest of the setup (Stalwart settings, DNS, Bulwark) is the same whichever proxy you pick. Stalwart's documentation has examples for Caddy, Traefik, Nginx, and HAProxy.

Mail ports never go through the proxy. Ports 25, 465, 587, 993, and 4190 are not HTTP, so they are published directly from the Stalwart container.


Prerequisites

  • A VPS with a public IPv4 address. Stalwart is light, so 2 vCPU and 4 GB RAM is enough for a personal server.
  • A domain whose DNS is hosted in Cloudflare. Stalwart uses the Cloudflare API to publish mail records and to get its certificate.
  • Outbound port 25 open at your VPS provider. Some providers block it by default. Without it, you cannot deliver mail to other servers.
  • Access to set reverse DNS (PTR) for the VPS IP in your provider's control panel.
  • An SSH client on your computer (Windows PowerShell includes ssh).

Step 1: Install the operating system

In your VPS provider's control panel, install Debian 13 (trixie) with no application or control panel. Do not pick an image with Plesk or cPanel; those take ports 25, 80, and 443.

A reinstalled server has a new SSH host key. If you connected to this IP or hostname before, SSH refuses to connect and shows WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!.

Try removing the old entries first. In PowerShell on your computer:

ssh-keygen -R 203.0.113.10
ssh-keygen -R mail.example.com

If the warning still appears, remove the entry by hand:

  1. Read the warning. It names the exact file and line, for example Offending ECDSA key in C:\Users\<you>/.ssh/known_hosts:5.
  2. List every known hosts file in your SSH folder:

    Get-ChildItem $env:USERPROFILE\.ssh\known_hosts*
    
  3. Open the file the warning named (it can be known_hosts or another file in that list) in Notepad:

    notepad $env:USERPROFILE\.ssh\known_hosts
    
  4. Delete the lines that start with the server's IP or hostname (or the line number from the warning), save, and connect again.

Note: ssh-keygen -R saves a backup of the original file as known_hosts.old in the same folder.


Step 2: Provider firewall

In your provider's firewall, allow these inbound ports and block everything else:

Port Protocol Purpose
22 TCP SSH (limit to your own IP if you can)
25 TCP Inbound mail from other servers
80 TCP Caddy certificate requests and HTTP redirect
443 TCP and UDP HTTPS and HTTP/3 through Caddy
465 TCP Mail submission from clients (implicit TLS)
587 TCP Mail submission with STARTTLS, for apps and devices that cannot use 465 (see Port 587 for apps that need STARTTLS)
993 TCP IMAP (implicit TLS)
4190 TCP ManageSieve (optional, only for desktop filter editors)

Do not open 3000 or 8080. The setup screens are reached through an SSH tunnel.

Note: Docker publishes ports by writing its own iptables rules, which bypass ufw on the host. The provider firewall is your real perimeter.


Step 3: Reverse DNS

In your provider's control panel, set the reverse DNS (PTR) record for the VPS IPv4 address to mail.example.com.

Check it from any machine:

dig -x 203.0.113.10 +short

Expected output: mail.example.com.


Step 4: Cloudflare DNS records

Go to Cloudflare dashboard › example.com › DNS › Records and add two records. Set both to DNS only (grey cloud):

Type Name Content Proxy status
A mail 203.0.113.10 DNS only
A webmail 203.0.113.10 DNS only

Do not create MX, SPF, DKIM, DMARC, SRV, or the autoconfig, autodiscover, mta-sts, or ua-auto-config records by hand. Stalwart creates all of them in Step 9. Records you create by hand can conflict with the ones Stalwart tries to write.

Do not add an AAAA record for mail unless you also set an IPv6 PTR record.


Step 5: Cloudflare API token for Stalwart

Stalwart needs an API token that can edit DNS for your domain only.

  1. In Cloudflare, click your profile icon (top right) › My Profile › API Tokens.
  2. Click Create Token.
  3. Next to the Edit zone DNS template, click Use template.
  4. Set Token name to something you will recognize, for example stalwart-example-dns.
  5. Under Zone Resources, set: Include › Specific zone › example.com.
  6. Click Continue to summary, then Create Token.
  7. Copy the token value from the final screen and store it in a password manager. Cloudflare shows it only once.

Warning: Use an API Token, not the Global API Key listed under "API Keys" on the same page. The Global API Key controls your entire Cloudflare account, and Stalwart rejects it with the error 6111 Invalid format for Authorization header.

You will test the token on the VPS in Step 6.


Step 6: Prepare the server and install Docker

SSH into the VPS as root:

ssh root@mail.example.com

Update the system and set the hostname:

apt update && apt -y full-upgrade
hostnamectl set-hostname mail.example.com
apt -y install ca-certificates curl dnsutils netcat-openbsd

Make sure nothing on the host already uses the mail or web ports:

ss -tlnp | grep -E ':(25|80|443|465|587|993|4190)\b'

The command should print nothing. If exim4 or postfix appears, remove it:

apt -y purge exim4* postfix && apt -y autoremove

Install Docker from Docker's official repository:

install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt update
apt -y install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
docker compose version

Confirm outbound port 25 is open:

nc -vz -w5 gmail-smtp-in.l.google.com 25

Expected output ends with succeeded. If it times out, ask your provider to unblock outbound port 25 before going further.

Test the Cloudflare token from Step 5. The first line waits for you to paste the token; what you paste is not shown on screen:

read -rs CF_TOKEN; echo
curl -s -H "Authorization: Bearer $CF_TOKEN" https://api.cloudflare.com/client/v4/user/tokens/verify | grep -o '"status":"[a-z]*"'
unset CF_TOKEN

Expected output: "status":"active"


Step 7: Create the stack files

Create the project folder and a file holding a temporary setup password for Stalwart:

mkdir -p /opt/mail && cd /opt/mail
echo "STALWART_RECOVERY_ADMIN=admin:$(openssl rand -hex 16)" > stalwart.env
chmod 600 stalwart.env

Create /opt/mail/docker-compose.yml:

cat > /opt/mail/docker-compose.yml <<'EOF'
services:
  stalwart:
    image: stalwartlabs/stalwart:v0.16
    container_name: stalwart
    restart: unless-stopped
    env_file: stalwart.env
    environment:
      - TZ=America/New_York
      - STALWART_PUBLIC_URL=https://mail.example.com
    ports:
      - "25:25"
      - "465:465"
      - "587:587"
      - "993:993"
      - "4190:4190"
      - "127.0.0.1:8080:8080"
    volumes:
      - stalwart-etc:/etc/stalwart
      - stalwart-data:/var/lib/stalwart

  bulwark:
    image: ghcr.io/bulwarkmail/webmail:latest
    container_name: bulwark
    restart: unless-stopped
    environment:
      - TZ=America/New_York
      - HOSTNAME=0.0.0.0
      - PORT=3000
    volumes:
      - bulwark-settings:/app/data/settings
      - bulwark-admin:/app/data/admin
      - bulwark-admin-state:/app/data/admin-state
      - bulwark-telemetry:/app/data/telemetry
    healthcheck:
      test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    depends_on:
      - stalwart

  caddy:
    image: caddy:2
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      - stalwart
      - bulwark

volumes:
  stalwart-etc:
  stalwart-data:
  bulwark-settings:
  bulwark-admin:
  bulwark-admin-state:
  bulwark-telemetry:
  caddy-data:
  caddy-config:
EOF

What each choice does:

Setting Why
stalwartlabs/stalwart:v0.16 Current image name, pinned to one minor version so an update cannot jump to a version with breaking changes.
Volumes at /etc/stalwart and /var/lib/stalwart The only two paths the image writes to. A volume mounted anywhere else is ignored and data is lost when the container is recreated.
587:587 Mail submission with STARTTLS for apps that cannot use 465. Stalwart has no listener on 587 by default, so connections are refused until you add one in Port 587 for apps that need STARTTLS.
127.0.0.1:8080 Stalwart's setup screen, reachable only from the server itself (through an SSH tunnel). Removed in Step 15.
No ports: on Bulwark Bulwark is only reached through Caddy on the internal Docker network, so it publishes nothing on the host.
STALWART_PUBLIC_URL Tells Stalwart its public address, since Caddy handles HTTPS in front of it.

Create /opt/mail/Caddyfile:

cat > /opt/mail/Caddyfile <<'EOF'
mail.example.com, autoconfig.example.com, autodiscover.example.com, mta-sts.example.com, ua-auto-config.example.com {
    reverse_proxy stalwart:8080
}

webmail.example.com {
    reverse_proxy bulwark:3000
}
EOF

The first block sends the mail hostname and the four client-setup hostnames to Stalwart. The second sends webmail to Bulwark. Bulwark's first-run setup page becomes reachable once Caddy starts, but it cannot be used without a one-time token that is only printed in the container log (Step 14).

Validate the compose file:

cd /opt/mail
docker compose config --quiet && echo OK

Expected output: OK

Warning: Always use --quiet. Without it, docker compose config prints the setup password from stalwart.env on screen.


Step 8: Start Stalwart and run its setup wizard

Start Stalwart and Bulwark (not Caddy yet):

cd /opt/mail
docker compose up -d stalwart bulwark
docker compose ps

Both containers should show Up.

Get the temporary admin password. This prints the password on screen:

cat /opt/mail/stalwart.env

The password is the part after admin:.

On your own computer, open a new PowerShell window and create an SSH tunnel to Stalwart's setup port. Leave this window open until Step 15:

ssh -N -L 18080:127.0.0.1:8080 root@mail.example.com

After you enter your password, the window looks frozen. That is normal; -N keeps the tunnel open without starting a shell.

In your browser, go to:

http://127.0.0.1:18080/admin

Sign in with username admin and the password from stalwart.env. The setup wizard opens. Fill it in:

Wizard screen What to set
Server identity Server hostname: mail.example.com. Default email domain: example.com. Leave Automatically obtain TLS certificate and Generate email signing keys checked.
Storage Keep RocksDB and "Use data store" for every store. Check that the RocksDB Path starts with /var/lib/stalwart; any other path is outside the data volume.
Account directory Use the Internal Directory.
Logging Log destination: Console. A log file inside a container is lost on restart.
DNS management Choose Cloudflare and paste the API token from Step 5 into the secret field.

The final screen shows a permanent admin account ([email protected]) and a generated password. Save both now. They are shown only once.

Restart Stalwart so the new configuration takes effect:

cd /opt/mail
docker compose restart stalwart

Step 9: Confirm DNS records and the certificate

Watch the log for DNS and certificate activity:

cd /opt/mail
docker compose logs -f --since 5m stalwart | grep -Ei 'dns|acme|certificate|task'

A successful run looks like this, in order:

  1. Many DNS record created lines (MX, SPF, DKIM, DMARC, SRV, CNAME records).
  2. ACME order started with type dns-01.
  3. ACME order completed, listing a validity window of about 90 days.

Press Ctrl+C when you see ACME order completed.

If the DNS task failed

If the log shows Task failed during processing ... DnsManagement, fix the cause first. The most common one is 6111 Invalid format for Authorization header, which means the wrong Cloudflare credential was pasted.

  1. Go to http://127.0.0.1:18080/admin and sign in as [email protected].
  2. Switch to Settings using the icons at the bottom of the left sidebar.
  3. Go to Network › DNS › DNS Providers › open the Cloudflare entry.
  4. Clear the Secret field, paste the API token from Step 5, and click Save.

A failed task does not run again on its own, so create a new one:

  1. Switch to Management and go to Tasks › Scheduled › create (+).
  2. Task type: open the list and scroll to the bottom. Choose Perform DNS management for a domain.
  3. Record Types: click Select options... and select every type.
  4. Renew Certificate on Success: turn on.
  5. Domain: example.com.
  6. Status: leave as Pending task awaiting execution.
  7. Due: today, with the time set to now.
  8. Click Create, then watch the log again.

Verify the certificate

openssl s_client -connect mail.example.com:993 -servername mail.example.com </dev/null 2>/dev/null | openssl x509 -noout -subject -issuer -dates

The issuer= line should name Let's Encrypt.


Step 10: Review the DNS records Stalwart created

Go to Cloudflare dashboard › example.com › DNS › Records and check these items.

Delete the CAA records. Stalwart publishes two CAA records at the root of the domain:

example.com  CAA  0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/..."
example.com  CAA  0 iodef "mailto:[email protected]"

The accounturi value allows only Stalwart's own Let's Encrypt account to issue certificates for the whole domain. Caddy uses a different account, so its certificate requests fail. So can certificates for any other site on the domain. Delete both CAA records, then confirm:

dig +short CAA example.com @1.1.1.1

Expected output: nothing.

Confirm the MX record exists. Stalwart's DNS task does not always leave one in place. Check:

dig +short MX example.com @1.1.1.1

Expected output: 10 mail.example.com. If nothing prints, click Add record and create it:

Field Value
Type MX
Name @
Mail server mail.example.com
Priority 10

Without an MX record, two things break. Some senders cannot deliver to the domain. And the SPF record v=spf1 mx -all matches no server, so receivers reject your outgoing mail as an SPF failure.

Keep SPF on mx, not a fixed IP. If you edit the SPF record, keep v=spf1 mx -all (or ~all). A record such as v=spf1 ip4:203.0.113.10 ~all covers only IPv4. Mail the server sends over IPv6 then fails SPF at receivers that accept IPv6, such as Gmail.

Check proxy status. The CNAME records autoconfig, autodiscover, mta-sts, and ua-auto-config must be DNS only (grey cloud). If any show an orange cloud, click Edit and switch them to DNS only.

Remove the POP3 SRV record if you do not use POP3. Stalwart publishes _pop3s._tcp.example.com, but port 995 is not published in this setup. Delete that SRV record so mail apps do not try POP3.

Consider relaxing DMARC at first. Stalwart publishes _dmarc.example.com with p=reject. That tells receiving servers to reject any message that fails checks. On a new server, a common approach is to start with monitoring only, then tighten after a week or two of clean reports. To do that, edit the _dmarc TXT record to:

v=DMARC1; p=none; rua=mailto:[email protected]

Step 11: Prepare Stalwart for the reverse proxy

Do this before starting Caddy.

Stalwart bans any IP that requests exploit-style paths such as /info.php or /.env. Internet scanners send those requests within minutes of a new site going live. Behind Caddy, every request reaches Stalwart from Caddy's container IP, so without these settings Stalwart bans Caddy itself and every web request returns 502.

Find the Docker network's subnet:

docker network inspect mail_default --format '{{(index .IPAM.Config 0).Subnet}}'

Example output: 172.18.0.0/16. Use your own output below.

In http://127.0.0.1:18080/admin, switch to Settings and change these three pages. Click Save on each page before leaving it.

  1. Network › HTTP › General
    • Turn on Obtain remote IP from Forwarded header. Stalwart then sees and bans the real client IP instead of Caddy's.
  2. Network › HTTP › Security
    • Turn on Permissive CORS policy. Bulwark runs in the browser on webmail.example.com and calls JMAP on mail.example.com, which needs CORS.
  3. Security › Allowed IPs
    • Add the Docker subnet from the command above (for example 172.18.0.0/16) so Caddy and the tunnel can never be banned.

Trusting the forwarded header is safe here only because port 8080 is never exposed to the internet. Only Caddy and the local 127.0.0.1 binding can reach it.

Restart Stalwart. Saving is not enough: the CORS policy only takes effect after a restart.

cd /opt/mail
docker compose restart stalwart

Confirm CORS is active:

curl -si -X OPTIONS http://127.0.0.1:8080/jmap/ \
  -H 'Origin: https://webmail.example.com' \
  -H 'Access-Control-Request-Method: POST' \
  -H 'Access-Control-Request-Headers: authorization,content-type' | grep -i '^access-control'

Expected output includes access-control-allow-origin: *. If nothing prints, reopen Network › HTTP › Security, check that the toggle stayed on, and restart again.


Step 12: Start Caddy

cd /opt/mail
docker compose up -d caddy
docker compose logs -f caddy

Wait for certificate obtained successfully for mail.example.com, the four autoconfig hostnames, and webmail.example.com, then press Ctrl+C.

Test:

curl -sI https://mail.example.com/admin | head -1

Expected output: HTTP/2 302 or HTTP/2 200.

From now on, the Stalwart admin console is at https://mail.example.com/admin.


Step 13: Remove the temporary admin account

The recovery admin from stalwart.env is a backdoor login. Remove it once the permanent admin works over HTTPS:

cd /opt/mail
: > stalwart.env
docker compose up -d stalwart

Step 14: Set up Bulwark

Confirm Bulwark can reach Stalwart's JMAP endpoint through Caddy:

cd /opt/mail
docker compose exec bulwark wget -S --spider https://mail.example.com/.well-known/jmap 2>&1 | grep "HTTP/"

Expected output: a redirect (307) followed by 200 OK.

Bulwark's setup wizard needs a one-time token printed in its log. The token expires after 1 hour, so restart Bulwark to get a fresh one. This prints the token on screen:

docker compose restart bulwark
sleep 10
docker compose logs --since 1m bulwark | grep -iE 'token|setup'

Go to https://webmail.example.com and fill in the wizard:

Wizard screen What to set
Welcome Paste the setup token (64 hex characters).
Server JMAP server URL: https://mail.example.com. Click Test. Keep Enable Stalwart-specific features checked.
Auth Leave Enable OAuth2 / OpenID Connect unchecked. Basic authentication is always available.
Security Keep the generated session secret. Check Sync user settings across devices so preferences follow the user between browsers. The anonymous usage stats option is your choice; it does not change how webmail works.
Logging Keep the defaults.
Branding Optional: organization name, favicon, login logos, website URL.
Review Confirm the server URL, then finish. Set the Bulwark admin password and save it.

The Bulwark admin password is only for Bulwark's own settings (branding, plugins, policies) at https://webmail.example.com/admin. It is not a mailbox login.


Step 15: Close the setup port

Stalwart's admin console now works at https://mail.example.com/admin, so the local setup port is no longer needed. Remove it:

cd /opt/mail
sed -i '/127.0.0.1:8080:8080/d' docker-compose.yml
docker compose config --quiet && echo OK
docker compose up -d
ss -tlnp | grep -E ':8080\b'

The last command should print nothing. Close the SSH tunnel window; it is no longer needed. Caddy still reaches Stalwart over the internal Docker network.


Step 16: Create a mailbox and test

  1. Go to https://mail.example.com/admin and sign in as [email protected].
  2. Go to Management › Directory › Accounts › create a new account:
    • Name: the part before the @, for example user
    • Domain: example.com
    • A strong password
  3. Go to https://webmail.example.com and sign in as [email protected].
  4. Send a message to yourself, then to an outside address (Gmail, Outlook.com) and reply back.
  5. Send a message to the address shown at mail-tester.com and check the score.

Next step (optional): to sign in to webmail with Authentik single sign-on, see Stalwart and Bulwark with Authentik SSO.


Mail app settings

Most apps (Thunderbird, Apple Mail, Outlook) configure themselves from the autoconfig records when you enter the email address and password. If an app asks for manual settings:

Server Port Security
Incoming (IMAP) mail.example.com 993 SSL/TLS
Outgoing (SMTP) mail.example.com 465 SSL/TLS
Username Full email address, for example [email protected]

Choose IMAP, not POP3, so every device sees the same mailbox. For outgoing mail, prefer 465 with SSL/TLS. Use 587 with STARTTLS only for an app that cannot do 465 (see the next section).

Calendars and contacts use CalDAV and CardDAV on mail.example.com. Thunderbird, iOS, and macOS support them natively. On Android, use DAVx5.

If two-factor authentication is turned on for a mailbox, mail apps need an app password, created at https://mail.example.com/account.


Port 587 for apps that need STARTTLS

Why 587 is needed

There are two ways to send mail securely to a mail server:

Port How the encryption starts Setting name in most apps
465 Encrypted from the first byte (implicit TLS) SSL/TLS, SMTPS
587 Starts in plain text, then the app sends STARTTLS and switches to encryption STARTTLS, TLS, Submission

Both are equally secure once connected. The problem is that many apps support only one of them. A lot of self-hosted applications, network scanners and printers, NAS units, and monitoring tools only offer STARTTLS on port 587. They have no setting for implicit TLS. Pointed at port 465, they send plain text to a port that expects encryption, and the connection hangs until it times out.

For example, Wardrowbe's email notifications only use STARTTLS, so they cannot send through port 465 at all.

Stalwart only creates the 465 listener by default. Adding a 587 listener lets these apps send through the same server without changing anything else.

Add the listener

  1. Open the Stalwart admin console at https://mail.example.com/admin.
  2. Open the Listeners page (titled Manage SMTP, IMAP, HTTP, and other listeners) and create a new listener:

    Field Value
    Name submission
    Protocol SMTP
    Bind address [::]:587
    Implicit TLS Off

    Implicit TLS must be off. With it on, port 587 behaves like port 465 and every STARTTLS app fails, which defeats the purpose of the listener. Compare with the existing listeners: submissions on 465 has Implicit TLS on, and smtp on 25 has it off. Port 587 must match port 25. Stalwart offers STARTTLS on a listener that has Implicit TLS off, using the same certificate as the other listeners.

  3. Save, then restart Stalwart so the new listener starts:

    cd /opt/mail
    docker compose restart stalwart
    
  4. Check that port 587 is open on the server:

    ss -tlnp | grep ':587\b'
    

    Expected: a docker-proxy line listening on 0.0.0.0:587. If nothing appears, the 587:587 line is missing from docker-compose.yml (Step 7). Add it and run docker compose up -d.

Test from the machine that will send

Run this on the host or container of the app that will use port 587, not on the mail server:

timeout 8 openssl s_client -starttls smtp -connect mail.example.com:587 -brief </dev/null 2>&1 | head -3

Expected:

CONNECTION ESTABLISHED
Protocol version: TLSv1.3
Result Cause
CONNECTION ESTABLISHED Port 587 with STARTTLS works
Connection refused Port 587 is not published (docker-compose.yml), or the listener does not exist yet
Hangs, then didn't find starttls in server response or a timeout The listener has Implicit TLS on. Turn it off and restart Stalwart
Times out with no output at all The provider firewall blocks TCP 587 (Step 2)

App settings for port 587

Setting Value
Server mail.example.com
Port 587
Security STARTTLS (sometimes labeled TLS)
Username Full email address, for example [email protected]
Password The mailbox password, or an app password if two-factor authentication is on
From address The same address as the username. Stalwart normally rejects mail sent as an address the login does not own

Maintenance

Updates

cd /opt/mail
docker compose pull
docker compose up -d

Read the Stalwart and Bulwark release notes before updating. Once the setup is stable, pin Bulwark to a release tag instead of latest so updates happen only when you choose.

Editing the Caddyfile

After any change to /opt/mail/Caddyfile, restart the container instead of running caddy reload:

cd /opt/mail
docker compose restart caddy

The Caddyfile is mounted into the container as a single file. sed -i and many editors save by writing a new file, and the running container keeps seeing the old one, so caddy reload reloads the old version. A restart mounts the current file.

Backups

All mail, accounts, settings, and DKIM keys live in the mail_stalwart-data volume. Stop Stalwart briefly for a consistent copy:

cd /opt/mail
docker compose stop stalwart
tar -czf /root/stalwart-data-$(date +%F).tar.gz -C /var/lib/docker/volumes/mail_stalwart-data/_data .
docker compose start stalwart

Copy the archive off the server. If the DKIM keys are lost, outgoing signatures break until the DNS records are republished. Test a restore at least once.

Bulwark's volumes hold only webmail preferences and branding. Losing them does not lose mail.

Check for returning CAA records

Stalwart may publish a CAA record again during later DNS management runs. If Caddy starts failing certificate renewals, check:

dig +short CAA example.com @1.1.1.1

Send Stalwart's log to the console

If docker compose logs stalwart prints nothing, Stalwart is not writing its log to the console.

  1. In https://mail.example.com/admin, switch to Settings and go to Telemetry › Logging.
  2. Open the Console tracer, or click Create and set Tracer type to Console.
  3. Set Enable this tracer to on, Logging level to Info, and Buffered writes to off. Buffered writes delay log lines, which makes the log look empty while you troubleshoot.
  4. Click Save, then restart Stalwart and check:

    cd /opt/mail
    docker compose restart stalwart
    docker compose logs --since 1m stalwart | tail -20
    

    Expected output: lines such as Network listener started ... localPort = 993.

Server-side mail filters (Sieve)

Sieve filters run on the server, so they apply to every device. Examples: forward a copy of all mail, or move reports into a folder. You can upload a filter over ManageSieve (port 4190) from the server itself.

  1. Install the client and write the filter. This example forwards a copy of every message, files DMARC reports into a folder named DMARC, and keeps everything else in the Inbox:

    apt install -y sieve-connect
    cat > /root/main.sieve <<'EOF'
    require ["fileinto", "copy"];
    redirect :copy "[email protected]";
    if address :contains "from" "dmarc" {
        fileinto "DMARC";
        stop;
    }
    keep;
    EOF
    

    Create the DMARC folder in webmail first. Do not combine keep and fileinto for the same message: the message then lands in both the Inbox and the folder.

  2. Upload and activate it. Run each command on its own line, and paste the account's password at each prompt (its app password, if the account signs in with SSO):

    sieve-connect --server mail.example.com --port 4190 --user [email protected] --upload --localsieve /root/main.sieve --remotesieve main
    
    sieve-connect --server mail.example.com --port 4190 --user [email protected] --activate --remotesieve main
    
    sieve-connect --server mail.example.com --port 4190 --user [email protected] --list
    

    The last command should list main as active. Pasting several commands at once can feed the next command into the password prompt and fail the login.


Troubleshooting

Symptom Cause Fix
services.<name>.ports must be a array A ports: key was left with no entries under it. Remove the empty ports: line under that service, or restore the entry.
WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! when connecting over SSH The server was reinstalled and has a new host key. Remove the old entry (Step 1).
Stalwart log: 6111 Invalid format for Authorization header The Cloudflare Global API Key was used instead of an API Token. Replace the secret (Step 9), then create a new DNS management task.
Stalwart log repeats No TLS certificates available The DNS management task failed, so the certificate request never started. Fix the DNS error and create a new DNS management task (Step 9).
https://mail.example.com returns 502 and the Stalwart log shows Blocked IP address ... remoteIp = 172.18.x.x Stalwart auto-banned Caddy's container IP after scanner traffic. Do Step 11, delete the entry under Settings › Security › Blocked IPs, then docker compose restart stalwart. The block list is kept in memory, so deleting the entry without the restart changes nothing.
Caddy fails to get a certificate A CAA record restricts issuance to Stalwart's account, or a record is proxied (orange cloud). Delete the CAA records and set the records to DNS only (Step 10).
Browser says a site "sent an invalid response" Caddy has no certificate for that hostname, often because it never loaded an edited Caddyfile. docker compose restart caddy (see Editing the Caddyfile).
Bulwark wizard rejects the setup token The token expired (1 hour). Restart Bulwark and read the new token from the log (Step 14).
Webmail sign-in ends at Authentication Failed. The browser Network tab (F12) shows CORS error on jmap. The Bulwark log may also show Token exchange failed with invalid_grant. CORS is off in Stalwart, or it was turned on without a restart. The invalid_grant is a side effect: Bulwark retries with a login code that was already used. Step 11: turn on Permissive CORS policy, restart Stalwart, and run the CORS check.
Receivers reject outgoing mail with SPF fail - not authorized for the server's IP The domain has no MX record, so mx in the SPF record matches nothing. Add the MX record (Step 10).
Gmail shows SPF pass, but other receivers report SPF fail (or the reverse) The SPF record lists only an ip4: address, so mail sent over IPv6 fails. Use v=spf1 mx -all (Step 10).
Outbound mail stays queued and the log shows timeouts on port 25 The VPS provider blocks outbound port 25. Ask the provider to unblock it.
An app sending on port 587 gets Connection refused Port 587 is not published, or Stalwart has no listener on 587. Add 587:587 to docker-compose.yml and create the listener (Port 587 for apps that need STARTTLS).
An app sending on port 587 hangs, then times out The 587 listener has Implicit TLS on, so it expects encryption before the app sends STARTTLS. Turn Implicit TLS off on the 587 listener and restart Stalwart.
An app that only supports STARTTLS times out on port 465 Port 465 uses implicit TLS, which the app cannot speak. Point the app at port 587 instead.

References