How do I set up Stalwart (docker) behind Caddy (docker)?

Your question

TL;DR
I always leave ports 80 and 443 of the Docker host open for Caddy.
My intention is to leave only ports 25, 465, and 993 of the Docker host open for Stalwart.
I need to configure Caddy to forward all HTTPS communication directed to mail.mydomain.com to the Stalwart container.
Caddy should also not attempt to obtain a certificate, the Stalwart container itself will handle obtaining and renewing the certificate necessary for the email server to function.


Hi everyone!

I have a small VPS where I do my experiments and learning.

I’ve always been a fan of Caddy, I think it’s the best web server/reverse proxy. It’s simple to configure, a look at the Caddyfile and you can understand almost everything. I usually create a Docker network called “caddy_net” for the Caddy container and for the containers that will use Caddy as a reverse proxy for one or more of their services.

I reinstalled my VPS from scratch with Debian 13.4 and initially I will only have three Docker stacks: Caddy, Dockhand, and Stalwart.

The current Caddy configuration allows me to:

  • access Dockhand via HTTPS at https://dockhand.mydomain.com
  • view the default Caddy web server page at https://vps.mydomain.com
  • access Stalwart via HTTPS for initial configuration at https://stalwart.mydomain.com
  ~/docker
 ├──  caddy
 │   ├──  .env
 │   ├──  Caddyfile
 │   ├──  compose.yml
 │   ├──  config
 │   ├──  data
 │   └──  Dockerfile
 └──  stalwart
     ├──  .env
     ├──  compose.yml
     ├──  data
     └──  etc

~/docker/caddy/Dockerfile

FROM caddy:2.11.3-builder AS builder
RUN xcaddy build \
  --with github.com/caddy-dns/cloudflare \
  --with github.com/mholt/caddy-events-exec \
  --with github.com/mholt/caddy-l4
FROM caddy:2.11.3
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

~/docker/caddy/.env

[email protected]
CF_API_TOKEN=<your-api-token-goes-here>
PUID=1000
PGID=1000
TZ=America/Sao_Paulo

~/docker/caddy/compose.yml

services:
  caddy:
    image: caddy-custom:v2.11.3
    build: .
    pull_policy: build
    user: "${PUID}:${PGID}"
    hostname: caddy
    container_name: caddy
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:80"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s
    environment:
      ACME_EMAIL: "${ACME_EMAIL}"
      CF_API_TOKEN: "${CF_API_TOKEN}"
      TZ: "${TZ:-America/Sao_Paulo}"
    volumes:
      - "./Caddyfile:/etc/caddy/Caddyfile"
      - "./config:/config/caddy"
      - "./data:/data/caddy"
    cap_add:
      - NET_BIND_SERVICE
networks:
  default:
    name: caddy_net
    external: true

~/docker/caddy/Caddyfile

{
        acme_ca https://acme-v02.api.letsencrypt.org/directory
        acme_dns cloudflare {$CF_API_TOKEN}
        email {$ACME_EMAIL}
        renew_interval 45m
}

dockhand.mydomain.com {
        reverse_proxy http://dockhand:3000
}

stalwart.mydomain.com {
        reverse_proxy http://stalwart:8080
}

vps.mydomain.com {
        root * /usr/share/caddy
        file_server
}

~/docker/stalwart/.env

PUID=1000
PGID=1000
TZ=America/Sao_Paulo

~/docker/stalwart/compose.yml

services:
  stalwart:
    image: stalwartlabs/stalwart:v0.16.5
    user: "${PUID}:${PGID}"
    hostname: mail.mydomain.com
    container_name: stalwart
    restart: unless-stopped
    ports:
      - 25:25
      - 465:465
      - 993:993
    environment:
      TZ: "${TZ:-America/Sao_Paulo}"
    volumes:
      - "./etc:/etc/stalwart"
      - "./data:/var/lib/stalwart"
networks:
  default:
    name: caddy_net
    external: true

This is the complete list of Caddy modules added by the xcaddy build:

/srv $ caddy list-modules --skip-standard
caddy.listeners.layer4
dns.providers.cloudflare
events.handlers.exec
layer4
layer4.handlers.close
layer4.handlers.echo
layer4.handlers.proxy
layer4.handlers.proxy_protocol
layer4.handlers.socks5
layer4.handlers.subroute
layer4.handlers.tee
layer4.handlers.throttle
layer4.handlers.tls
layer4.handlers.vars
layer4.matchers.clock
layer4.matchers.dns
layer4.matchers.http
layer4.matchers.local_ip
layer4.matchers.not
layer4.matchers.openvpn
layer4.matchers.postgres
layer4.matchers.proxy_protocol
layer4.matchers.quic
layer4.matchers.rdp
layer4.matchers.regexp
layer4.matchers.remote_ip
layer4.matchers.remote_ip_list
layer4.matchers.socks4
layer4.matchers.socks5
layer4.matchers.ssh
layer4.matchers.tls
layer4.matchers.vars
layer4.matchers.vars_regexp
layer4.matchers.winbox
layer4.matchers.wireguard
layer4.matchers.xmpp
layer4.proxy.selection_policies.first
layer4.proxy.selection_policies.ip_hash
layer4.proxy.selection_policies.least_conn
layer4.proxy.selection_policies.random
layer4.proxy.selection_policies.random_choose
layer4.proxy.selection_policies.round_robin
tls.handshake_match.alpn

  Non-standard modules: 43

Let me research the relevant Stalwart documentation on TLS, reverse proxy, and certificate management.

Great, I now have all the information needed to give a thorough answer. Let me compose the response.

Your setup is a great fit for the documented Stalwart + Caddy pattern. Since you already built a custom Caddy image with caddy-l4, you have everything needed. Here is exactly what to do:


The core idea

Your goal (Stalwart owns its own cert, Caddy just passes TCP through) maps to the TCP-passthrough / layer-4 proxy pattern. Caddy forwards raw TCP on ports 25, 465, and 993 directly to the Stalwart container, and separately reverse-proxies HTTPS at mail.mydomain.com to Stalwart’s HTTPS listener. Stalwart handles TLS end-to-end on mail ports; Caddy handles TLS termination for the web/admin UI.

One important caveat: Caddy must not issue a certificate for mail.mydomain.com in this setup, because Stalwart owns that cert. You accomplish this by using the layer4 block for HTTPS passthrough on port 443 toward Stalwart (SNI-matched), while your other subdomains (dockhand, vps) continue to use Caddy’s normal HTTPS termination.


Revised architecture

Since Caddy already binds 443, you have two clean choices:

Option A (simplest, matches your stated goal): Caddy terminates TLS for mail.mydomain.com on port 443 and reverse-proxies to Stalwart’s plain HTTP port 8080 for the web/admin UI, while using layer4 to forward ports 25, 465, 993 directly to Stalwart with Proxy Protocol v2. Stalwart manages its own cert for the mail ports. This is the “HTTP upstream” pattern and is what the official Caddy doc recommends.

Option B (full passthrough): Use layer4 with SNI matching to pass port 443 traffic for mail.mydomain.com straight through to Stalwart’s HTTPS listener (port 443 inside the container), and Stalwart issues its own cert for everything including HTTPS. This is more complex and requires remapping Caddy’s own HTTPS port.

Option A is far simpler. Here is a working configuration for it:


Stalwart compose.yml changes

Expose Stalwart’s HTTPS and HTTP ports on the Docker network (not necessarily the host), and keep mail ports on the host. Also set STALWART_PUBLIC_URL:

services:
  stalwart:
    image: stalwartlabs/stalwart:v0.16.5
    user: "${PUID}:${PGID}"
    hostname: mail.mydomain.com
    container_name: stalwart
    restart: unless-stopped
    ports:
      - "25:25"
      - "465:465"
      - "993:993"
      # Do NOT expose 443 or 8080 to the host; Caddy reaches them via caddy_net
    environment:
      TZ: "${TZ:-America/Sao_Paulo}"
      STALWART_PUBLIC_URL: "https://mail.mydomain.com"
    volumes:
      - "./etc:/etc/stalwart"
      - "./data:/var/lib/stalwart"
networks:
  default:
    name: caddy_net
    external: true

The STALWART_PUBLIC_URL variable is critical: it tells Stalwart what base URL to publish in its OAuth, OIDC, and JMAP discovery documents so that mail clients and the WebUI can find all endpoints correctly.


Revised Caddyfile

{
    acme_ca https://acme-v02.api.letsencrypt.org/directory
    acme_dns cloudflare {$CF_API_TOKEN}
    email {$ACME_EMAIL}
    renew_interval 45m

    layer4 {
        # Forward SMTP (port 25) to Stalwart with Proxy Protocol v2
        0.0.0.0:25 {
            route {
                proxy {
                    proxy_protocol v2
                    upstream stalwart:25
                }
            }
        }
        # Forward SMTPS (port 465) to Stalwart with Proxy Protocol v2
        0.0.0.0:465 {
            route {
                proxy {
                    proxy_protocol v2
                    upstream stalwart:465
                }
            }
        }
        # Forward IMAPS (port 993) to Stalwart with Proxy Protocol v2
        0.0.0.0:993 {
            route {
                proxy {
                    proxy_protocol v2
                    upstream stalwart:993
                }
            }
        }
    }
}

# Caddy terminates TLS here; Stalwart gets plain HTTP on port 8080
mail.mydomain.com {
    reverse_proxy http://stalwart:8080
}

dockhand.mydomain.com {
    reverse_proxy http://dockhand:3000
}

vps.mydomain.com {
    root * /usr/share/caddy
    file_server
}

Key point: Because mail.mydomain.com is defined as a normal Caddy site block, Caddy will obtain and renew a cert for it using your Cloudflare DNS challenge. Stalwart handles its own certs only for SMTP/IMAP (ports 25, 465, 993), not for the HTTPS web UI. This is actually the recommended setup: let Caddy own the cert for HTTPS, and let Stalwart use ACME or a cert you copy from Caddy for the mail ports.


Stalwart ports mapping for layer4

Since Caddy is binding ports 25, 465, and 993 on the host (via the layer4 block), you must remove those from Stalwart’s ports: mapping (otherwise Docker will conflict). Stalwart’s mail ports are reached by Caddy via the caddy_net Docker network. Update your stalwart/compose.yml to remove the host port bindings for 25, 465, 993 entirely (use expose: instead if you want documentation, or just remove them):

services:
  stalwart:
    ...
    expose:
      - "25"
      - "465"
      - "993"
      - "8080"
    # No 'ports:' section - Caddy reaches Stalwart via caddy_net

And in caddy/compose.yml, add the mail ports to the Caddy container’s ports::

ports:
  - "80:80"
  - "443:443"
  - "443:443/udp"
  - "25:25"
  - "465:465"
  - "993:993"

Enable Proxy Protocol on Stalwart’s listeners

For the Proxy Protocol headers from Caddy to be accepted, you need to enable Proxy Protocol on Stalwart’s SMTP and IMAP listeners. You do this in the Stalwart WebUI under Settings > Network > Listeners, enabling “Proxy Protocol” on each of the SMTP (25), SMTPS (465), and IMAPS (993) listeners, and adding Caddy’s container IP (or the caddy_net subnet) as a trusted proxy source.


Initial setup tip

During first-run setup (bootstrap mode), access the wizard directly at http://<stalwart-container-ip>:8080/admin or temporarily expose port 8080 to the host, complete the wizard, then remove the direct exposure. The docs specifically recommend bypassing the proxy for initial setup.


Reference docs:

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

@helpbot you missed the main point: as far as I understand (and have tested), it’s not necessary to expose ports 25, 465, and 993 in Caddy.

That only seems to make the configuration more complex.

Since no other container is exposing ports 25, 465, and 993, the Stalwart container can expose these ports without any conflict.

But I verified in my tests that I couldn’t authenticate a user created in Stalwart using Thunderbird.

Only after doing the initial configuration using stalwart.mydomain.com (which Caddy redirected using HTTP on port 8080 of the Stalwart container), disabling the Caddy container, exposing port 443 of the Stalwart container, and restarting it was possible to use the email of the user I created in Stalwart.

So, I believe I just need to adjust the configuration in the Caddyfile so that Caddy forwards, without any changes, the HTTPS traffic arriving at mail.mydomain.com directly to port 443 of the Stalwart container.

You’re correct that ports 25, 465, and 993 can stay on Stalwart directly with no port conflict since nothing else is using them. The real issue you hit is the HTTPS passthrough requirement: you want Caddy to forward mail.mydomain.com on port 443 to Stalwart’s HTTPS listener without Caddy terminating TLS for that hostname.

The documented pattern for this with Caddy is to use caddy-l4 with SNI matching to selectively pass through mail.mydomain.com traffic at the TCP level, while Caddy still terminates TLS normally for your other sites. Since you already have caddy-l4 built in, this is straightforward.

The key: SNI-based TCP passthrough in the layer4 block

In your global layer4 block, match TLS connections for mail.mydomain.com by SNI and pass them through to Stalwart’s port 443, before Caddy’s HTTPS listener gets a chance to terminate them. For your other domains, Caddy handles TLS normally.

{
    acme_ca https://acme-v02.api.letsencrypt.org/directory
    acme_dns cloudflare {$CF_API_TOKEN}
    email {$ACME_EMAIL}
    renew_interval 45m

    layer4 {
        0.0.0.0:443 {
            route {
                match {
                    tls sni mail.mydomain.com
                }
                proxy {
                    upstream stalwart:443
                }
            }
        }
    }
}

dockhand.mydomain.com {
    reverse_proxy http://dockhand:3000
}

vps.mydomain.com {
    root * /usr/share/caddy
    file_server
}

Notice that mail.mydomain.com is removed from the Caddyfile site blocks entirely. Caddy will not try to obtain a certificate for it because it never sees that hostname as an HTTP/HTTPS site block – the layer4 block intercepts those connections at the TCP level before Caddy’s HTTP layer.

Stalwart compose.yml

Keep ports 25, 465, 993 on Stalwart as you have them. Do not expose port 443 to the host (Caddy already binds it); Caddy reaches Stalwart’s 443 via caddy_net:

services:
  stalwart:
    image: stalwartlabs/stalwart:v0.16.5
    user: "${PUID}:${PGID}"
    hostname: mail.mydomain.com
    container_name: stalwart
    restart: unless-stopped
    ports:
      - "25:25"
      - "465:465"
      - "993:993"
      # 443 is NOT published to the host; Caddy reaches it via caddy_net
    environment:
      TZ: "${TZ:-America/Sao_Paulo}"
      STALWART_PUBLIC_URL: "https://mail.mydomain.com"
    volumes:
      - "./etc:/etc/stalwart"
      - "./data:/var/lib/stalwart"
networks:
  default:
    name: caddy_net
    external: true

Why authentication failed before

The problem you hit with Thunderbird was almost certainly STALWART_PUBLIC_URL being unset. When it is unset, Stalwart publishes discovery URLs based on https://<defaultHostname>:443, but because Caddy was intercepting 443 and not passing it through, those URLs didn’t resolve correctly through the proxy. Setting STALWART_PUBLIC_URL=https://mail.mydomain.com ensures the OAuth/JMAP/IMAP autodiscovery URLs all point to the right place.

One caveat

The layer4 block intercepts port 443 globally, so it must use SNI matching to let non-mail.mydomain.com traffic fall through to Caddy’s normal HTTPS handling. The config above handles this: only SNI=mail.mydomain.com is proxied; everything else on port 443 continues to be handled by Caddy’s site blocks as normal.

Reference: Caddy reverse proxy guide and Reverse proxy overview

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

@helpbot tried your suggestion, got this error:
`Error: adapting config using caddyfile: parsing caddyfile tokens for ‘layer4’: getting module named ‘layer4.handlers.match’: module not registered: layer4.handlers.match, at /etc/caddy/Caddyfile:11`

The layer4.handlers.match module isn’t part of the caddy-l4 plugin – the correct module name for SNI matching within a layer4 route is layer4.matchers.tls (which you do have registered), and the Caddyfile syntax for it is different from what was suggested. Unfortunately the exact Caddyfile syntax for SNI-based passthrough using caddy-l4’s route/match directives is not covered in the Stalwart docs – the official Caddy doc example uses a plain reverse_proxy https://... block (with Caddy re-encrypting to Stalwart’s HTTPS listener), not a true TCP passthrough.

The simplest approach that is fully documented is to have Caddy terminate TLS for mail.mydomain.com and forward plain HTTP to Stalwart on port 8080, while Stalwart handles its own certs only for the mail ports (25, 465, 993). If you specifically need Stalwart to own the HTTPS cert too, the documented pattern from the Caddy guide is to use reverse_proxy with transport http { tls_server_name ... } – Caddy re-encrypts to Stalwart’s HTTPS listener on port 443 via the Docker network (not a raw passthrough):

mail.mydomain.com {
    reverse_proxy https://stalwart:443 {
        transport http {
            tls_server_name mail.mydomain.com
        }
    }
}

With this, Caddy does obtain its own cert for mail.mydomain.com (using your Cloudflare DNS challenge), and separately forwards over HTTPS to Stalwart which also needs a valid cert. This is the pattern shown in the official Caddy guide. For the Stalwart-side cert in this case, you can either use ACME within Stalwart, or copy Caddy’s cert into Stalwart as shown in the “Certificate management” section of that same doc.

The exact caddy-l4 Caddyfile syntax for SNI passthrough on port 443 is a Caddy-specific question beyond what the Stalwart docs cover – a community member familiar with caddy-l4 may be able to help with the correct syntax.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong. If you found it useful, mention @helpbot in any reply to ask follow-up questions and the bot will respond again.

I’ve made progress with this Caddyfile:

{
        acme_ca https://acme-v02.api.letsencrypt.org/directory
        acme_dns cloudflare {$CF_API_TOKEN}
        email {$ACME_EMAIL}
        renew_interval 45m

        layer4 {
                :443 {
                        @mail tls sni mail.mydomain.com
                        route @mail {
                                proxy {
                                        proxy_protocol v2
                                        upstream stalwart:443
                                }
                        }
                }
        }
}

dockhand.mydomain.com {
        reverse_proxy http://dockhand:3000
}

stalwart.mydomain.com {
        reverse_proxy http://stalwart:8080
}

vps.mydomain.com {
        root * /usr/share/caddy
        file_server
}

I’ll continue with my tests and then report back here.