Traefik TLS Termination for Mail Protocols¶
This document explains the architectural pattern for exposing mail server protocols (SMTP, IMAP, POP3) through Traefik with proper TLS termination.
The Problem: Why TLS Passthrough Fails with Mailu¶
When deploying a mail server like Mailu behind Traefik, a naive approach might use TLS passthrough - letting Traefik forward encrypted traffic directly to the mail server’s nginx frontend without decryption. This seems logical because:
Mailu’s
frontcomponent includes nginx with TLS configurationMailu supports
TLS_FLAVOR=certto use externally provided certificatesLess complexity - just pass traffic through
However, this approach leads to an HTTP 301 redirect loop for webmail/admin access:
sequenceDiagram
participant Client
participant Traefik
participant Mailu as Mailu Front<br/>(TLS_FLAVOR=cert)
Client->>Traefik: HTTPS request
Traefik->>Mailu: HTTP request (TLS terminated)
Mailu->>Traefik: HTTP 301 redirect to HTTPS
Traefik->>Client: HTTP 301 redirect to HTTPS
Client->>Traefik: HTTPS request
Note over Client,Mailu: Infinite redirect loop!
Root Cause: When Traefik terminates TLS and forwards HTTP to Mailu, the TLS_FLAVOR=cert configuration triggers nginx’s redirect server block that unconditionally redirects HTTP→HTTPS, creating an infinite loop.
Why can’t we bypass Mailu’s front component?
Mailu’s front (nginx) is not optional - it provides critical functionality:
Authentication gateway: Uses nginx
auth_requestdirective for all servicesReverse proxy: Routes to admin, webmail, and other components
Protocol termination: Handles mail protocol specifics (SMTP, IMAP, POP3)
Rate limiting: Protects against brute-force attacks
The Solution: Traefik TLS Termination for All Protocols¶
The correct architecture is to disable TLS in Mailu entirely and let Traefik handle all TLS termination:
graph TB
Client([Mail Client / Browser])
subgraph "Traefik Ingress"
HTTPS[HTTPS :443<br/>Kubernetes Ingress]
SMTPS[SMTPS :465<br/>IngressRouteTCP]
SMTP_SUB[Submission :587<br/>TCP passthrough]
IMAPS[IMAPS :993<br/>IngressRouteTCP]
POP3S[POP3S :995<br/>IngressRouteTCP]
end
subgraph "cert-manager"
Cert[Let's Encrypt Certificate<br/>Secret: mailu-tls]
end
subgraph "Traefik Configuration"
TLSOpt[TLSOption: mailu-mail-tls<br/>Min: TLS 1.2<br/>Ciphers: ECDHE+AEAD]
end
subgraph "Mailu Cluster"
Front[Front nginx<br/>TLS_FLAVOR=notls<br/>HTTP only]
Admin[Admin]
Webmail[Webmail]
SMTP_SVC[SMTP Postfix]
IMAP_SVC[IMAP Dovecot]
end
Client -->|TLS| HTTPS & SMTPS & IMAPS & POP3S
Client -->|Plaintext| SMTP_SUB
HTTPS -.uses.-> Cert
SMTPS & IMAPS & POP3S -.uses.-> Cert
SMTPS & IMAPS & POP3S -.uses.-> TLSOpt
HTTPS -->|HTTP| Front
SMTPS -->|HTTP| Front
SMTP_SUB -->|HTTP| Front
IMAPS -->|HTTP| Front
POP3S -->|HTTP| Front
Front --> Admin & Webmail & SMTP_SVC & IMAP_SVC
classDef tlsNode fill:#90EE90
classDef httpNode fill:#FFB6C1
class HTTPS,SMTPS,IMAPS,POP3S,Cert,TLSOpt tlsNode
class Front,Admin,Webmail,SMTP_SVC,IMAP_SVC httpNode
Key Configuration Changes:
Mailu: Set
TLS_FLAVOR=notls- nginx serves HTTP only, no redirectsTraefik: Terminate TLS for HTTPS (cert-manager) and mail protocols (IngressRouteTCP with TLSOption)
Certificate: Single Let’s Encrypt certificate from cert-manager, shared across all protocols
Critical Challenge: TLS_FLAVOR=notls Doesn’t Listen on Mail Ports¶
Setting TLS_FLAVOR=notls creates a critical problem: Mailu’s nginx configuration generator (/config.py) does not create listener directives for mail protocol ports (465, 587, 993, 995) when TLS is disabled.
Why this happens:
Mailu’s config.py assumes that if TLS is disabled (TLS_FLAVOR=notls), you’re running mail protocols without encryption. The generated nginx config only includes:
Port 25 (SMTP, always enabled)
Port 80 (HTTP for admin/webmail)
Without port listeners, Traefik IngressRouteTCP routes fail:
Client → Traefik:993 (TLS) → Mailu Front:993
↓
Connection Refused!
(nginx not listening)
Solution: Wrapper Script to Patch nginx Configuration¶
The solution is a wrapper script that intercepts Mailu’s startup sequence to inject mail protocol listeners into the generated nginx configuration:
sequenceDiagram
participant K8s as Kubernetes
participant Wrapper as Wrapper Script<br/>(entrypoint-wrapper.sh)
participant ConfigPy as Mailu config.py
participant Nginx as nginx
K8s->>Wrapper: Start container
Wrapper->>ConfigPy: Execute /config.py
ConfigPy->>ConfigPy: Generate nginx.conf<br/>(TLS_FLAVOR=notls)
ConfigPy-->>Wrapper: Config generated
Note over Wrapper: nginx.conf has ONLY<br/>port 25 and 80
Wrapper->>Wrapper: Patch /etc/nginx/nginx.conf<br/>Inject server blocks for<br/>465, 587, 993, 995
Note over Wrapper: nginx.conf now has<br/>ALL required ports
Wrapper->>Nginx: Start nginx
Nginx->>Nginx: Listen on 25, 80,<br/>465, 587, 993, 995
Wrapper Script Implementation:
The wrapper script is stored in a ConfigMap and mounted into the Front container:
#!/bin/sh
# Mailu Front wrapper script with nginx configuration patch
set -e
# Step 1: Run Mailu's config.py to generate nginx configuration
python3 /config.py
# Step 2: Patch /etc/nginx/nginx.conf to add mail protocol listeners
sed -i '/auth_http_header Auth-Port 25;/,/^ }$/{
/^ }$/a\
\
# Submission (port 587) for Traefik TLS termination\
server {\
listen 587;\
protocol smtp;\
smtp_auth plain;\
auth_http_header Auth-Port 587;\
auth_http_header Client-Port $remote_port;\
}\
\
# SMTPS (port 465) for Traefik TLS termination\
server {\
listen 465;\
protocol smtp;\
smtp_auth plain;\
auth_http_header Auth-Port 465;\
auth_http_header Client-Port $remote_port;\
}\
\
# IMAPS (port 993) for Traefik TLS termination\
server {\
listen 993;\
protocol imap;\
imap_auth plain;\
auth_http_header Auth-Port 993;\
auth_http_header Client-Port $remote_port;\
}\
\
# POP3S (port 995) for Traefik TLS termination\
server {\
listen 995;\
protocol pop3;\
pop3_auth plain;\
auth_http_header Auth-Port 995;\
auth_http_header Client-Port $remote_port;\
}
}' /etc/nginx/nginx.conf
# Step 3: Start Dovecot proxy (required by Mailu)
dovecot -c /etc/dovecot/proxy.conf
# Step 4: Start nginx
exec /usr/sbin/nginx -g "daemon off;"
What the wrapper does:
Executes original Mailu config.py - preserves all standard Mailu configuration
Locates port 25 server block - finds the anchor point in nginx config
Injects mail protocol server blocks - adds listeners for 465, 587, 993, 995 as siblings to port 25
Starts Dovecot proxy - required for nginx mail authentication
Starts nginx - with complete configuration including mail ports
CDK8S Integration:
The wrapper is implemented as a TypeScript construct in cdk8s-mailu:
// generic-charts/cdk8s-mailu/src/constructs/nginx-patch-configmap.ts
export class NginxPatchConfigMap extends Construct {
public readonly configMap: kplus.ConfigMap;
constructor(scope: Construct, id: string, props: NginxPatchConfigMapProps) {
this.configMap = new kplus.ConfigMap(this, 'configmap', {
metadata: {
namespace: props.namespace.name,
labels: {
'app.kubernetes.io/name': 'mailu-nginx-patch',
'app.kubernetes.io/component': 'configuration',
},
},
data: {
'entrypoint-wrapper.sh': wrapperScript, // Full script embedded
},
});
}
}
// generic-charts/cdk8s-mailu/src/constructs/front-construct.ts
export class FrontConstruct extends Construct {
constructor(scope: Construct, id: string, props: FrontConstructProps) {
// ... deployment creation ...
if (props.nginxPatchConfigMap) {
// Override container command to use wrapper
containerConfig.command = ['/bin/sh', '/usr/local/bin/entrypoint-wrapper.sh'];
// Mount wrapper script from ConfigMap
const patchVolume = kplus.Volume.fromConfigMap(
this,
'nginx-patch-volume',
props.nginxPatchConfigMap,
{ defaultMode: 0o755 } // Executable
);
container.mount('/usr/local/bin/entrypoint-wrapper.sh', patchVolume, {
subPath: 'entrypoint-wrapper.sh',
readOnly: true,
});
}
}
}
Security Considerations:
✅ Script is read-only - mounted from ConfigMap, cannot be modified at runtime
✅ Immutable - changes require ConfigMap deletion and pod restart
✅ Auditable - script content is in git, visible in manifests
⚠️ Runs as root - required for nginx to bind privileged ports (< 1024)
⚠️ Modifies container state - sed patches generated config file
⚠️ Non-standard - deviates from upstream Mailu container behavior
Why this approach vs. alternatives:
Approach |
Pros |
Cons |
Verdict |
|---|---|---|---|
Wrapper script (chosen) |
Preserves Mailu logic, minimal changes, easy to audit |
Runs as root, modifies files |
✅ Best balance |
Fork Mailu image |
Full control |
Maintenance burden, security updates delayed |
❌ Too complex |
Custom nginx config |
Simple |
Loses Mailu templating, breaks updates |
❌ Fragile |
Init container |
Cleaner separation |
Same root/modification issues |
≈ Equivalent |
Implementation Details¶
1. Certificate Management with cert-manager¶
Use a standard Kubernetes Ingress with cert-manager annotation to provision Let’s Encrypt certificate:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: mailu-webmail
namespace: mailu
annotations:
cert-manager.io/cluster-issuer: letsencrypt-cluster-issuer
spec:
ingressClassName: traefik
tls:
- hosts:
- mail.example.com
secretName: mailu-tls # Certificate stored here
rules:
- host: mail.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: mailu-front-service
port:
number: 80
Why Kubernetes Ingress instead of Traefik IngressRoute?
Traefik IngressRoute with
certResolverdoesn’t integrate with cluster’s existing cert-managerKubernetes Ingress works with cluster’s centralized certificate management
Single certificate shared across HTTP and mail protocols
2. TLSOption for Mail Protocol Security¶
Mail clients expect specific TLS configurations (minimum TLS 1.2, specific cipher suites). Define a TLSOption matching Mailu’s default TLS security settings:
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
name: mailu-mail-tls
namespace: mailu
spec:
minVersion: VersionTLS12
cipherSuites:
# Matches Mailu's core/nginx/conf/tls.conf
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305
Why these specific cipher suites?
Security: Only strong AEAD ciphers (GCM, ChaCha20-Poly1305)
Compatibility: Supported by all modern mail clients
Performance: Hardware-accelerated AES-GCM on most CPUs
Match Mailu: Uses same ciphers as Mailu’s built-in TLS (security equivalence)
Client cipher preference: Traefik defaults to client-preferred cipher selection (matching Mailu’s ssl_prefer_server_ciphers off), allowing clients to choose their most performant cipher.
3. IngressRouteTCP for Mail Protocols¶
Each secure mail protocol needs an IngressRouteTCP resource with TLS termination:
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: mailu-smtps
namespace: mailu
spec:
entryPoints:
- smtps # Port 465 in Traefik configuration
routes:
- match: HostSNI(`*`)
services:
- name: mailu-front-service
port: 465
tls:
secretName: mailu-tls # cert-manager certificate
options:
name: mailu-mail-tls
namespace: mailu
Required for: SMTPS (465), IMAPS (993), POP3S (995)
Not required for:
SMTP port 25 (no TLS, direct passthrough)
Submission port 587 (STARTTLS negotiated by mail server, not Traefik)
4. Traefik LoadBalancer Configuration¶
The Traefik service must expose all mail protocol ports via LoadBalancer:
# In kube-hetzner/kube.tf
traefik_additional_ports = [
{ name = "smtp", port = 25, exposedPort = 25 },
{ name = "smtps", port = 465, exposedPort = 465 },
{ name = "smtp-submission", port = 587, exposedPort = 587 },
{ name = "imaps", port = 993, exposedPort = 993 },
{ name = "pop3s", port = 995, exposedPort = 995 }
]
Port Security Considerations:
Exposed secure ports: Only TLS-encrypted or STARTTLS-capable
SMTP port 25: Required for receiving mail from other servers (cannot use encryption)
Insecure IMAP (143) and POP3 (110): NOT exposed (force clients to use TLS)
CDK8S Implementation Considerations¶
When implementing this pattern with CDK8S (TypeScript-based Kubernetes manifests), several challenges arise from dynamic naming.
Service Name Discovery¶
CDK8S generates unique service names with hash suffixes (e.g., mailu-front-service-c89f5122). IngressRouteTCP resources need to reference these dynamic names.
Problem: Circular dependency - can’t create IngressRouteTCP until service exists, but need to reference service name.
Solution: Three-chart architecture:
graph LR
subgraph "Chart 1: Infrastructure"
NS[Namespace]
Secrets[Secrets]
ConfigMap[ConfigMap]
end
subgraph "Chart 2: Mailu Application"
Front[Front Construct<br/>→ Service<br/>hash: c89f5122]
Admin[Admin Construct<br/>→ Service<br/>hash: c8ad3a23]
Webmail[Webmail Construct<br/>→ Service<br/>hash: d9f4b35c]
end
subgraph "Chart 3: Ingress"
K8sIngress[Kubernetes Ingress<br/>→ frontService.name]
SMTPS_Route[IngressRouteTCP SMTPS<br/>→ frontService.name]
IMAPS_Route[IngressRouteTCP IMAPS<br/>→ frontService.name]
POP3S_Route[IngressRouteTCP POP3S<br/>→ frontService.name]
end
NS --> Front & Admin & Webmail
Front -.service reference.-> K8sIngress & SMTPS_Route & IMAPS_Route & POP3S_Route
classDef infra fill:#E6F3FF
classDef app fill:#FFE6E6
classDef ingress fill:#E6FFE6
class NS,Secrets,ConfigMap infra
class Front,Admin,Webmail app
class K8sIngress,SMTPS_Route,IMAPS_Route,POP3S_Route ingress
Implementation:
// Chart 1: Infrastructure (namespace, secrets, configmaps)
new MailuInfrastructureChart(app, 'mailu-infrastructure', config);
// Chart 2: Mailu application (creates services with dynamic names)
const mailuChart = new MailuChart(app, 'mailu', mailuConfig);
// Chart 3: Ingress (created AFTER MailuChart, references frontService)
const ingressChart = new Chart(app, 'mailu-ingress');
if (!mailuChart.frontConstruct) {
throw new Error('MailuChart frontConstruct not initialized');
}
new IngressConstruct(ingressChart, 'ingress', {
frontService: mailuChart.frontConstruct.service, // Pass service reference
// ... other config
});
Key principle: Pass service references between constructs, not hardcoded names.
Full Kubernetes DNS Names Required¶
Mailu’s nginx configuration uses environment variables to discover other services:
ADMIN_ADDRESS=mailu-admin-service-c8ad3a23
WEBMAIL_ADDRESS=mailu-webmail-service-d9f4b35c
Problem: Short names don’t resolve with CDK8S hash-based naming (DNS search domains unreliable).
Solution: Always use full Kubernetes DNS names:
private updateConfigMapWithServiceDiscovery(): void {
const namespace = this.config.namespace;
if (this.adminConstruct?.service) {
this.sharedConfigMap.addData('ADMIN_ADDRESS',
`${this.adminConstruct.service.name}.${namespace}.svc.cluster.local`);
}
if (this.webmailConstruct?.service) {
this.sharedConfigMap.addData('WEBMAIL_ADDRESS',
`${this.webmailConstruct.service.name}.${namespace}.svc.cluster.local`);
}
}
Important: Don’t include ports in addresses - Mailu’s nginx templates add ports automatically.
Component Initialization Order¶
Mailu components have dependencies that require careful initialization order:
graph LR
subgraph "Phase 1: Bootstrap"
CM1[Create ConfigMap<br/>with static values]
end
subgraph "Phase 2: Components"
Admin[Create Admin<br/>→ service]
Front[Create Front<br/>→ service]
Webmail[Create Webmail<br/>→ service]
end
subgraph "Phase 3: Service Discovery"
CM2[Update ConfigMap<br/>ADMIN_ADDRESS<br/>FRONT_ADDRESS<br/>WEBMAIL_ADDRESS]
end
CM1 --> Admin & Front & Webmail
Admin & Front & Webmail --> CM2
Admin -.reads.-> CM1
Front -.reads.-> CM1
Webmail -.reads.-> CM1
CM2 -.used by.-> Front
classDef phase1 fill:#FFE6E6
classDef phase2 fill:#E6F3FF
classDef phase3 fill:#E6FFE6
class CM1 phase1
class Admin,Front,Webmail phase2
class CM2 phase3
Problem: ConfigMap needs service addresses, but services don’t exist at ConfigMap creation time.
Solution: Three-phase initialization:
constructor() {
// Phase 1: Create ConfigMap with static values
this.createSharedConfigMap();
// Phase 2: Create all components (which reference ConfigMap)
if (config.components?.admin) this.createAdminComponent();
if (config.components?.front) this.createFrontComponent();
if (config.components?.webmail) this.createWebmailComponent();
// ... other components
// Phase 3: Update ConfigMap with service discovery (AFTER all components created)
this.updateConfigMapWithServiceDiscovery();
}
This ensures all services exist before attempting to discover their addresses.
Verification and Testing¶
1. Certificate Verification¶
Check that cert-manager provisioned the certificate:
# Check certificate status
kubectl get certificate -n mailu
# Should show:
# NAME READY SECRET AGE
# mailu-tls True mailu-tls 2m
2. TLS Configuration Verification¶
Test TLS connection to mail protocols:
# SMTPS (port 465)
openssl s_client -connect mail.example.com:465 -starttls smtp
# IMAPS (port 993)
openssl s_client -connect mail.example.com:993
# Check cipher suite
openssl s_client -connect mail.example.com:993 | grep "Cipher"
3. HTTP Access Verification¶
Test webmail access (should return HTTP 200 or redirect to login):
curl -I https://mail.example.com/webmail
Expected: HTTP 302 redirect to /sso/login (Mailu SSO authentication)
Not expected: HTTP 301 redirect loop, connection refused, certificate errors
4. Mail Client Configuration¶
Configure a mail client (Thunderbird, Apple Mail, etc.) with:
Incoming (IMAP): mail.example.com, port 993, SSL/TLS
Outgoing (SMTP): mail.example.com, port 587, STARTTLS
Alternative outgoing: Port 465, SSL/TLS
All should connect successfully with valid certificate verification.
Benefits of This Architecture¶
Centralized certificate management: One cert-manager certificate for all protocols
Simplified Mailu configuration:
TLS_FLAVOR=notlsremoves nginx TLS complexityConsistent TLS policies: TLSOption enforces security across all mail protocols
Cluster-native approach: Uses Kubernetes Ingress, cert-manager (not Mailu’s Let’s Encrypt integration)
No redirect loops: Mailu serves HTTP only, no HTTP→HTTPS redirects
Standard Traefik patterns: Uses documented IngressRouteTCP approach