Skip to content

Traefik

Traefik is a reverse proxy for container deployments. Instead of a central configuration file for every route, it reads its routes from labels on the containers it serves, so each service declares how it is reached.

The example on this page runs Traefik and Stalwart in Docker Compose, on a shared network. HTTP and mail traffic take different paths through Traefik:

  • For HTTPS, Traefik terminates TLS and forwards plain HTTP to Stalwart’s HTTP listener on port 8080. It passes the client’s address in the X-Forwarded-For header.
  • The mail ports are TCP routes. Traefik forwards each connection unchanged to Stalwart, which terminates TLS itself, and sends the client’s address with the PROXY protocol.

Traefik also obtains the certificates. A helper container exports them as files, which Stalwart reads for the mail ports.

The following Compose file runs two containers. Traefik gets a fixed address on the proxy network, because Stalwart can trust the PROXY header only from a known address. Next to it runs the helper container that exports Traefik’s certificates.

The Compose project is assumed to be named traefik. Compose prefixes the names of networks and volumes with the project name, which gives the network and the certificate volume the names traefik_proxy and traefik_certs that the Stalwart project below refers to:

services:
traefik:
image: traefik:v3.7
container_name: traefik
restart: always
networks:
proxy:
ipv4_address: 172.19.0.2
ports:
- 80:80
- 443:443
- 25:25
- 465:465
- 993:993
environment:
- CF_DNS_API_TOKEN=<cloudflare api token>
volumes:
- /etc/traefik:/etc/traefik
- acme:/etc/certs
- /var/run/docker.sock:/var/run/docker.sock:ro
traefik-certs-dumper:
image: ghcr.io/kereis/traefik-certs-dumper:latest
container_name: traefik-certs-dumper
restart: unless-stopped
depends_on:
- traefik
volumes:
- acme:/traefik:ro
- certs:/output
volumes:
acme:
certs:
networks:
proxy:
ipam:
config:
- subnet: 172.19.0.0/16

The static configuration, /etc/traefik/traefik.yml, defines one entry point per port. It obtains certificates from Let’s Encrypt with the DNS challenge at Cloudflare, using the API token from the environment:

certificatesResolvers:
letsencrypt:
acme:
keyType: EC256
dnsChallenge:
provider: cloudflare
storage: /etc/certs/acme.json
entryPoints:
http:
address: :80
http:
redirections:
entryPoint:
to: https
scheme: https
https:
address: :443
http:
tls:
certResolver: letsencrypt
smtp:
address: :25
smtps:
address: :465
imaps:
address: :993
providers:
docker:
exposedByDefault: false

The following Compose file runs Stalwart on the same network. The labels declare the HTTP router and one TCP router per mail port. Port 8080 is not published on the host, so the HTTP listener can be reached only through Traefik and through other containers on the network:

services:
stalwart:
image: stalwartlabs/stalwart:v1
container_name: stalwart
init: true
restart: unless-stopped
networks:
proxy:
ipv4_address: 172.19.0.5
environment:
- STALWART_PUBLIC_URL=https://mail.example.com
volumes:
- etc:/etc/stalwart
- data:/var/lib/stalwart
- certs:/data/certs:ro
labels:
- traefik.enable=true
- traefik.http.routers.stalwart.rule=Host(`mail.example.com`) || Host(`autoconfig.example.com`) || Host(`autodiscover.example.com`) || Host(`mta-sts.example.com`) || Host(`ua-auto-config.example.com`)
- traefik.http.routers.stalwart.entrypoints=https
- traefik.http.routers.stalwart.tls.certresolver=letsencrypt
- traefik.http.routers.stalwart.service=stalwart
- traefik.http.services.stalwart.loadbalancer.server.port=8080
- traefik.tcp.routers.smtp.rule=HostSNI(`*`)
- traefik.tcp.routers.smtp.entrypoints=smtp
- traefik.tcp.routers.smtp.service=smtp
- traefik.tcp.services.smtp.loadbalancer.server.port=10025
- traefik.tcp.services.smtp.loadbalancer.proxyProtocol.version=2
- traefik.tcp.routers.smtps.rule=HostSNI(`*`)
- traefik.tcp.routers.smtps.tls.passthrough=true
- traefik.tcp.routers.smtps.entrypoints=smtps
- traefik.tcp.routers.smtps.service=smtps
- traefik.tcp.services.smtps.loadbalancer.server.port=10465
- traefik.tcp.services.smtps.loadbalancer.proxyProtocol.version=2
- traefik.tcp.routers.imaps.rule=HostSNI(`*`)
- traefik.tcp.routers.imaps.tls.passthrough=true
- traefik.tcp.routers.imaps.entrypoints=imaps
- traefik.tcp.routers.imaps.service=imaps
- traefik.tcp.services.imaps.loadbalancer.server.port=10993
- traefik.tcp.services.imaps.loadbalancer.proxyProtocol.version=2
volumes:
etc:
data:
certs:
name: traefik_certs
external: true
networks:
proxy:
name: traefik_proxy
external: true

Traefik sends the PROXY header without extensions, so the header cannot tell Stalwart whether the client used TLS. The TCP routes therefore use TLS passthrough: Stalwart performs the TLS handshake itself and knows that the connections are encrypted. POP3 and ManageSieve are added in the same way when they are used.

The setup wizard cannot be completed through Traefik. Traefik terminates TLS, and the wizard accepts sign-in only from the scheme and address that the request arrives with. For the setup, reach the wizard directly instead:

  1. Publish the setup port on the host’s loopback interface, by adding ports: ["127.0.0.1:8080:8080"] to the Stalwart service.
  2. Complete the wizard at http://127.0.0.1:8080/admin, on the host or through an SSH tunnel.
  3. Remove the port again.

Inside Stalwart, three things remain to configure: listeners for the TCP routes, the source of the client address for HTTP, and the certificate for the mail ports.

The following listeners receive the TCP routes. They accept the PROXY header from Traefik’s address only, so the default listeners keep accepting connections without the header:

[
{"name": "smtp-proxy", "protocol": "smtp", "bind": {"[::]:10025": true},
"overrideProxyTrustedNetworks": {"172.19.0.2": true}},
{"name": "submissions-proxy", "protocol": "smtp", "bind": {"[::]:10465": true}, "implicitTls": true,
"overrideProxyTrustedNetworks": {"172.19.0.2": true}},
{"name": "imaps-proxy", "protocol": "imap", "bind": {"[::]:10993": true}, "implicitTls": true,
"overrideProxyTrustedNetworks": {"172.19.0.2": true}}
]

The HTTP requests arrive from Traefik on the http listener, with the client’s address in X-Forwarded-For, to which Traefik appends. On the Http object (found in the WebUI under Settings › Network › HTTP › General, Settings › Network › HTTP › Security), clientIpSource makes Stalwart read that header, and forwardedHops is the number of proxies in front of Stalwart, here only Traefik:

{
"clientIpSource": "xForwardedFor",
"forwardedHops": 1
}

Another proxy or a CDN may sit in front of Traefik and also append to the header. In that case, set forwardedHops to 2, and list that proxy in forwardedHeaders.trustedIPs of Traefik’s https entry point. Without that entry, Traefik discards the incoming header.

The header is trusted on every HTTP listener, so no HTTP listener of Stalwart may be reachable other than through Traefik. Other containers attached to the proxy network can connect to port 8080 directly and claim any address. Attach only Traefik and Stalwart to that network.

The mail listeners need a certificate for mail.example.com, because Stalwart terminates TLS for them. Stalwart reads it from the files exported by the certificates dumper. A Certificate (found in the WebUI under Settings › TLS › Certificates) that reads them:

{
"certificate": {"@type": "File", "filePath": "/data/certs/mail.example.com/cert.pem"},
"privateKey": {"@type": "File", "filePath": "/data/certs/mail.example.com/key.pem"}
}

Set SystemSettings.defaultCertificateId to this certificate, so that it is also presented to clients that send no server name.

Stalwart reads the files only when it loads its certificates. After Traefik renews the certificate, the files change on disk, so reload them with the ReloadTlsCertificates action, as described in Certificates. A scheduled job can run the action, for example.