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

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

ACME_EMAIL=some.user@some.domain.com
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

@matt I’m having trouble using the “caddy-l4” modules; I’m not even sure if I can do what I want with them.

No one is better suited than you to shed some light on this subject. :slight_smile:

What have you tried already?

I tried to adapt what I have found here Caddy | Stalwart with another pieces I’ve found on Discord/Google.

I only need to forward HTTPS on port 443 directed at mail.mydomain.com to Stalwart container. All other ports on Stalwart documentation example are not needed because Stalwart container exposes them directly on host.

Appear to be simple but I cannot get it to work.

Just rebuilded everything. This is the actual Caddyfile:

{
        # acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
        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 stalwart:443
                        }
                        route {
                                tls
                                http
                        }
                }
        }
}

# ------------------------------------------------------------------------------

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
}

# ------------------------------------------------------------------------------

Then, Caddy container presents this error message on log:

{"level":"info","ts":1779309026.2223063,"msg":"using config from file","file":"/etc/caddy/Caddyfile"}
Error: adapting config using caddyfile: parsing caddyfile tokens for 'layer4': getting module named 'layer4.handlers.http': module not registered: layer4.handlers.http, at /etc/caddy/Caddyfile:16

I thought

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

would include all necessary modules.

That’s because there is no layer4.handlers.http module, only a layer4.matchers.http module. The HTTP handler would basically be the Caddy http app.

To achieve this, do I need your layer 4 modules, right?

Can you please show me the correct Caddyfile snippet and/or modules to add to Dockerfile for xcaddy build?

I thought to use something like this in Caddyfile:

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

but I need that Stalwart container manages HTTPS termination and certificate acquisition/renewal.

You can disable’s Caddy TLS termination with http://mail.mydomain.com { .

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.