Stalwart behind haproxy

Issue Description

I am trying to place my Stalwart dashboard behind a reverse proxy so it doesn’t have to use the self signed certificate. I am using haproxy, it’s the first (and only) layer in front of Stalwart. I have copied over some configuration of the docs and merged it with my existing config.

Expected Behavior

Certificates are obtained and no self-signed certificates are being used throughout.

Actual Behavior

Browser opens with certificate error, inspecting the certificate shows self-signed.

Reproduction Steps

  1. Configure Stalwart proxy settings
global
    log stdout format raw local0 info
    maxconn 1024

defaults
    mode tcp
    timeout client 30s
    timeout connect 30s
    timeout server 30s
    timeout http-request 30s
    log global

frontend http-in
    mode http
    bind :80
    default_backend server-http

    acl ACL_admin_mail hdr(host) mail.example.com
    use_backend be-stalwart-http if ACL_admin_mail

frontend https-in
    bind :443
    default_backend server-https
    option tcplog

    tcp-request inspect-delay 5s
    tcp-request content accept if { req.ssl_hello_type 1 }
    use_backend be-stalwart-https if { req.ssl_sni -m str mail.example.com }
    use_backend be-stalwart-https if { req.ssl_sni -m str mta-sts.example.com }
    use_backend be-stalwart-https if { req.ssl_sni -m str ua-auto-config.example.com }
    use_backend be-stalwart-https if { req.ssl_sni -m str autoconfig.example.com }
    use_backend be-stalwart-https if { req.ssl_sni -m str autodiscover.example.com }

backend be-stalwart-http
    mode http
    server server1 192.168.1.33:8080 check

backend be-stalwart-https
    mode tcp
    option tcplog
    server stalwart_mail_https 192.168.1.33:443 send-proxy-v2

Image there are other backend and rules as well, they do not collide.
Configured DNS zone including discovery subdomains.

mail.example.com
autoconfig.example.com
autodiscover.example.com
mta-sts.example.com
ua-auto-config.example.com
  1. Start Stalwart server and configure it using the onboarding process.
  2. Login with the generated [email protected] email and generated password
  3. Go to system settings and configure trusted proxies, then restart again. → Proxy Protocol | Stalwart
  4. Try using the HTTPS port

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

PostgreSQL

Blob Storage

PostgreSQL

Search Engine

PostgreSQL

Directory Backend

Internal

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

The self-signed certificate you’re seeing is very likely because Stalwart’s ACME certificate issuance hasn’t actually been configured yet — proxy protocol/trusted-networks settings only affect how Stalwart reads the client’s real IP, not certificate issuance. Setting trusted proxies alone will never obtain a Let’s Encrypt certificate; that requires a separate step.

To get real certificates instead of the self-signed fallback:

  1. Create an AcmeProvider object (Settings › TLS in the WebUI) with the ACME directory, contact email, and challenge type. The default challenge type is TlsAlpn01, which works fine with your TCP-passthrough HAProxy setup since HAProxy just forwards the raw TLS bytes over 443 to Stalwart based on SNI, and Stalwart itself completes the TLS-ALPN-01 challenge.
  2. For each Domain object (mail.example.com, autoconfig.example.com, etc.) set certificateManagement to Automatic and point acmeProviderId at the provider you created. Without this, the domain keeps using the self-signed fallback certificate regardless of proxy configuration.
  3. Confirm proxyTrustedNetworks in SystemSettings (or overrideProxyTrustedNetworks on the listener) includes the HAProxy source address so the Proxy Protocol header is actually honored — otherwise the connections are still processed but the client IP/TLS info will be wrong (this doesn’t affect certificates, but is worth verifying while you’re in there).
  4. If your dashboard is reached at a URL/port that differs from Stalwart’s defaultHostname on 443, set STALWART_PUBLIC_URL so ACME/OIDC discovery documents match what clients actually reach.

Since TlsAlpn01 needs Stalwart itself to answer on 443 with the real TLS handshake (which your TCP-passthrough config allows), this should work once the AcmeProvider and Domain certificate settings are actually in place. It’s normal to see a brief self-signed warning right after enabling this until the first certificate is issued.

See: ACME overview and TLS certificates for domains

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

In the onboarding of the server I set the server hostname to mail.example.com and the default email domain to example.com, where example.com is replaced by the actual domain name. That should cover point 4.

After the onboarding has finished and Stalwart has been restarted I set step 3. Currently setting it open very wide to the two smallest private CIDR ranges because that’s the two used in my network, once everything is working I will pin it to the exact IP.

Then I went onwards to step 1. I checked my AcmeProvider and it is pre-seeded with a TlsAlpn01 provider using letsencrypt. This is the provider that I want so I am keeping that.

I just have one domain object right now, that domain is failing to obtain a certificate. The DNS zone has been configured as shown in the UI. The certificate management is set to automatic and uses the earlier mentioned, and only, ACME provider.

The thing is that this exact configuration works if there is no proxy in front of it.

The weird thing is also that I don’t see any logs, the logs folder that was set by default during onboarding isn’t even created (at /var/log/stalwart). Neither do I see any logs in the Stalwart UI at Management > Observability > Logs. I also find no logs in the Docker container after I have restarted for the onboarding process to complete.

You have Proxy Protocol enabled, do you have the IP address of the proxy listed in TrustedNetworks? If not, it will not accept the connection as it is not expecting the header.

It sounds to me like you have an issue with proxying tls alpn request but not sure, try to configure http 01 instead, it is more straightforward to proxy, but you also need to make sure then that everything that is in subjectAlternativeNames of your Domain certificateManagement is proxied, and make sure you have localhost in proxyTrustedNetworks like “127.0.0.0/8” “::1”.
Maybe try to enable stdout tracer or something so you see logs and not try to guess.

Two things seem to be the issue here. First, the backend sends PROXY protocol (send-proxy-v2), and Stalwart drops any PROXY-protocol connection whose source isn’t in proxyTrustedNetworks before it does anything with it. If the haproxy host’s IP isn’t trusted, the connection is refused before the TLS/ACME challenge is ever processed, which is exactly why you get the self-signed fallback and nothing in the logs. Add the haproxy IP (and 127.0.0.0/8 and ::1) to proxyTrustedNetworks. Disrupt’s reply is pointing at the same thing. For details check the docs at Proxy Protocol | Stalwart.

Second, on the missing logs: the onboarding default writes to /var/log/stalwart, and if that directory doesn’t exist in the container the file tracer produces nothing. Enable a console/stdout tracer so you can see what’s happening instead of guessing; kanashimia’s suggestion there is the right move.

TLS-ALPN-01 can work through haproxy TCP passthrough with send-proxy-v2, but only once the trust check passes, so fix that first. If you’d rather not fight the ALPN path, HTTP-01 or DNS-01 are easier to reason about behind a proxy: HTTP-01 needs every name in the domain’s subjectAlternativeNames proxied on :80, DNS-01 needs automatic DNS management. Also double-check the SNI list in your https-in frontend covers every SAN on the certificate, or the challenge host won’t route to Stalwart.