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:

  1. Mailu’s front component includes nginx with TLS configuration

  2. Mailu supports TLS_FLAVOR=cert to use externally provided certificates

  3. Less 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:

  1. Authentication gateway: Uses nginx auth_request directive for all services

  2. Reverse proxy: Routes to admin, webmail, and other components

  3. Protocol termination: Handles mail protocol specifics (SMTP, IMAP, POP3)

  4. 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:

  1. Mailu: Set TLS_FLAVOR=notls - nginx serves HTTP only, no redirects

  2. Traefik: Terminate TLS for HTTPS (cert-manager) and mail protocols (IngressRouteTCP with TLSOption)

  3. 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:

  1. Executes original Mailu config.py - preserves all standard Mailu configuration

  2. Locates port 25 server block - finds the anchor point in nginx config

  3. Injects mail protocol server blocks - adds listeners for 465, 587, 993, 995 as siblings to port 25

  4. Starts Dovecot proxy - required for nginx mail authentication

  5. 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 certResolver doesn’t integrate with cluster’s existing cert-manager

  • Kubernetes 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

  1. Centralized certificate management: One cert-manager certificate for all protocols

  2. Simplified Mailu configuration: TLS_FLAVOR=notls removes nginx TLS complexity

  3. Consistent TLS policies: TLSOption enforces security across all mail protocols

  4. Cluster-native approach: Uses Kubernetes Ingress, cert-manager (not Mailu’s Let’s Encrypt integration)

  5. No redirect loops: Mailu serves HTTP only, no HTTP→HTTPS redirects

  6. Standard Traefik patterns: Uses documented IngressRouteTCP approach

References