TCP forward for subdomain on 443 without TLS termination

1. The problem I’m having:

I have Caddy as the main endpoint for port 443. I am trying to forward everything for the mail subdomain based on SNI raw to the docker container running stalwart, which than should be able to handle all the TLS and let’s encrypt parts by itself. I have set up the docker to forward host port 1443 to 443 inside the container. Otherwise I was following this solution here, which seemed to work for him: How do I set up Stalwart (docker) behind Caddy (docker)? - #6 by helpbot - Questions - Stalwart Support

Unfortunately, I cannot access Stalwart on port 443, only on 1443 (where the stalwart docker provides a selfsigned certificate). From what I think I read from the logs, Caddy is still trying to terminate TLS on mail . example . com, although I want to simply achieve passthrough of TCP traffic based on SNI.

I have redacted my domain to example . com, I used of course my own domain.

2. Error messages and/or full log output:

Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.642171,"logger":"tls.handshake","msg":"no matching certificates and no custom selection logic","identifier":"mail.example.com"}
Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.6421928,"logger":"tls.handshake","msg":"no matching certificates and no custom selection logic","identifier":"*.example.com"}
Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.6422,"logger":"tls.handshake","msg":"no matching certificates and no custom selection logic","identifier":"*.*.de"}
Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.642207,"logger":"tls.handshake","msg":"no matching certificates and no custom selection logic","identifier":"*.*.*"}
Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.6423032,"logger":"tls.handshake","msg":"no certificate matching TLS ClientHello","remote_ip":"46.128.9.75","remote_port":"48934","server_name":"mail.example.com","remote":"46.128.9.75:48934","identifier":"mail.example.com","cipher_suites":[4865,4867,4866,49195,49199,52393,52392,49196,49200,49162,49171,49172,156,157,47,53],"cert_cache_fill":0.0003,"load_or_obtain_if_necessary":true,"on_demand":false}
Jul 28 08:52:19 example.com caddy[78134]: {"level":"debug","ts":1785221539.6425354,"logger":"http.stdlib","msg":"http: TLS handshake error from 46.128.9.75:48934: no certificate available for 'mail.example.com'"}

3. Caddy version:

v2.11.4 h1:XKxkMTgNSizEvKG6QHue6cAsFOteU2qA61w2tKkCWi0=

4. How I installed and ran Caddy:

I use a custom build with the l4 plugin. Build was done using xcaddy build --with ``github.com/mholt/caddy-l4 and otherwise following this guide to install this to debian: Build from source — Caddy Documentation

a. System environment:

OS is Debian 13.

b. Command:

PASTE OVER THIS HERE IN THIS CODE BLOCK.
Please ensure it looks nice.

c. Service/unit/compose file:

services:
  stalwart-mail:
    image: stalwartlabs/stalwart:v0.16
    container_name: stalwart-mail
    restart: unless-stopped
    volumes:
      - ./data:/opt/stalwart
    ports:
      - "25:25"     # SMTP
      - "465:465"   # SMTPS (SMTP ueber SSL/TLS)
      - "993:993"   # IMAPS (IMAP ueber SSL/TLS)
      # - "8080:8080" # Stalwart Web-Admin-Oberflaeche (HTTP)
      - "1443:443"  # HTTPS für Web-Admin-Oberflaeche
      - "4190:4190" # Sieve

d. My complete Caddy config:

# The Caddyfile is an easy way to configure your Caddy web server.
#
# Unless the file starts with a global options block, the first
# uncommented line is always the address of your site.
#
# To use your own domain name (with automatic HTTPS), first make
# sure your domain's A/AAAA DNS records are properly pointed to
# this machine's public IP, then replace ":80" below with your
# domain name.
{
        debug

        layer4 {
                :443 {
                        @mail tls sni mail.example.com
                        route @mail {
                                proxy {
                                        proxy_protocol v2
                                        upstream localhost:1443
                                }
                        }
                }
        }
}

example.com {
        # Set this path to your site's directory.
        root * /usr/share/caddy

        # Enable the static file server.
        file_server
}

id.example.com {
        reverse_proxy http://localhost:1411
}

arcane.example.com {
        reverse_proxy http://localhost:3552
}

# Refer to the Caddy docs for more information

5. Links to relevant resources:

Caddy has to do SNI to know which host you want, since you have web servers configured as well. I don’t know the layer4 module but it seems like trying to run a layer4 proxy of TLS and a web server on the same port can’t work.

Why don’t you let Caddy handle all the TLS and proxy over http?

My understanding from the linked stalwart forum was, that this should be possible.
I want to do that, because the Stalwart Mail Server also needs the TLS certificate for secure SMTP and IMAP and I am trying to avoid the copy dance that is described in some places as a solution. Since Stalwart can do the ACME challenge, I am trying to directly forward all HTTPS traffic on that subdomain based on SNI and than have Stalwart handle all the certificate stuff itsel.

The post you linked to suggests having Caddy proxy HTTPS to HTTPS, which seems simpler. Caddy needs a certificate for the domain either way.

I misunderstood the layer4 as transparently passing on the TCP stream, while I guess now it’s actually terminating the TLS and proxying the TLS payload. So that should also work.