Configure Egress Gateway for Consistent Mail Source IP

This guide configures Cilium egress gateway to route all Mailu outbound traffic through a single node with a stable IP address, ensuring SPF compliance and better email deliverability.

See also

Why this is needed: Egress Gateway for SPF Compliance

Prerequisites

  • Cluster with Cilium 1.17.0+ installed

  • Control plane node designated as egress gateway (e.g., control-fsn1)

  • Existing Mailu deployment with email authentication (DKIM, DMARC) configured

Step 1: Enable Cilium Egress Gateway

Edit kube-hetzner/kube.tf to enable the egress gateway feature:

module "kube-hetzner" {
  # ... other configuration ...

  cilium_egress_gateway_enabled = true

  # ... rest of configuration ...
}

Step 2: Label Egress Gateway Node

Add the egress-gateway=true label to your designated control plane node:

control_plane_nodepools = [
  {
    name                 = "control-fsn1",
    server_type          = "cax21",
    location             = "fsn1",
    labels               = ["egress-gateway=true"],  # Egress gateway for Mailu
    taints               = [],
    count                = 1
    swap_size            = "2G"
    longhorn_volume_size = 0
    # Note: Using server's native IP for egress
    # PTR record set manually in Hetzner Console to mail.kup6s.com
  },
  # ... other nodes ...
]

Step 3: Apply Terraform Changes

Warning

ALWAYS use the Longhorn configuration script instead of tofu apply directly:

cd kube-hetzner
bash scripts/apply-and-configure-longhorn.sh

This script:

  1. Applies terraform changes

  2. Waits for Cilium to upgrade with egress gateway feature

  3. Configures Longhorn storage reservations correctly

Expected: Cilium pods will restart and the CiliumEgressGatewayPolicy CRD will be created.

Troubleshooting Cilium Upgrade

If Cilium pods hang during upgrade (waiting for CRD):

# Restart Cilium Operator to create the CRD
kubectl rollout restart deployment/cilium-operator -n kube-system

# Wait for rollout to complete
kubectl rollout status deployment/cilium-operator -n kube-system

# Verify CRD was created
kubectl get crd ciliumegressgatewaypolicies.cilium.io

Step 4: Get Egress Node IP Address

Retrieve the public IP of your egress gateway node:

kubectl get nodes <egress-node-name> -o jsonpath='{.status.addresses[?(@.type=="ExternalIP")].address}'

Example output: 5.75.247.168

Save this IP - you’ll need it for:

  • Egress policy configuration

  • DNS SPF record

  • PTR record

Step 5: Create Egress Gateway Policy

Create kube-hetzner/cilium-egress-mailu.yaml:

---
# CiliumEgressGatewayPolicy for Mailu Mail Server
# Routes all outbound traffic from Mailu namespace through designated egress node
# (control-fsn1) to ensure consistent source IP for mail delivery (SPF compliance)
#
# Using the server's native IP (not a floating IP) - if the node is replaced,
# the IP will change and DNS records will need updating.
# PTR record: Set manually in Hetzner Console to mail.kup6s.com
#
apiVersion: cilium.io/v2
kind: CiliumEgressGatewayPolicy
metadata:
  name: mailu-egress
spec:
  # Select all pods in the mailu namespace
  selectors:
  - podSelector:
      matchLabels:
        io.kubernetes.pod.namespace: mailu

  # Route to all external IPv4 destinations
  # Note: IPv6 egress gateway not supported in Cilium 1.17.0
  # IPv6 traffic will use node's native address automatically
  destinationCIDRs:
  - 0.0.0.0/0

  # Use control-fsn1 node as egress gateway
  egressGateway:
    nodeSelector:
      matchLabels:
        egress-gateway: "true"

    # Server's native IP for control-fsn1 node (retrieved: 2025-11-11)
    # kubectl get nodes kup6s-control-fsn1-jpq -o jsonpath='{.status.addresses[?(@.type=="ExternalIP")].address}'
    # PTR record: mail.kup6s.com (set manually in Hetzner Console)
    egressIP: 5.75.247.168

    # Note: For IPv6, Cilium will use the node's native IPv6 address
    # (2a01:4f8:c012:f3f0::1) automatically - no configuration needed

Replace 5.75.247.168 with your actual egress node IP.

Step 6: Apply Egress Policy

kubectl apply -f kube-hetzner/cilium-egress-mailu.yaml

Verify policy was created:

kubectl get ciliumegressgatewaypolicies.cilium.io

Expected output:

NAME           AGE
mailu-egress   10s

Step 7: Configure DNS PTR Records

Set reverse DNS (PTR records) in Hetzner Cloud Console to match your mail server hostname:

  1. Go to Hetzner Cloud ConsoleServers

  2. Click on your egress gateway server (e.g., kup6s-control-fsn1-jpq)

  3. Navigate to Networking tab

  4. Find Reverse DNS section

  5. For IPv4 (e.g., 5.75.247.168):

    • Click edit → Enter mail.kup6s.com → Save

  6. For IPv6 (e.g., 2a01:4f8:c012:f3f0::1):

    • Click edit → Enter mail.kup6s.com → Save

Verify DNS propagation (may take 1-5 minutes):

# Check IPv4 PTR
dig +short -x 5.75.247.168

# Check IPv6 PTR
dig +short -x 2a01:4f8:c012:f3f0::1

Both should return mail.kup6s.com.

Step 8: Update SPF DNS Record

Update your domain’s SPF TXT record to authorize the egress IP:

kup6s.com.  IN  TXT  "v=spf1 mx a:mail.kup6s.com ip4:5.75.247.168 ip6:2a01:4f8:c012:f3f0::1 ~all"

SPF components explained:

  • mx - Authorize IPs from MX records

  • a:mail.kup6s.com - Authorize A/AAAA records for mail.kup6s.com

  • ip4:5.75.247.168 - Explicitly authorize egress IPv4

  • ip6:2a01:4f8:c012:f3f0::1 - Explicitly authorize egress IPv6

  • ~all - Soft fail for other IPs (recommended over -all hard fail)

Step 9: Verify Egress Configuration

Test that Mailu pods are using the egress IP:

# Get a Mailu pod name
MAILU_POD=$(kubectl get pods -n mailu -l app.kubernetes.io/component=admin -o jsonpath='{.items[0].metadata.name}')

# Test IPv4 egress
kubectl exec -n mailu $MAILU_POD -- curl -s -4 ifconfig.me

Expected output: 5.75.247.168 (your egress IP)

Step 10: Test Email Delivery

Send a test email using mail-tester.com:

  1. Go to https://www.mail-tester.com

  2. Copy the test email address (e.g., test-abc123@srv1.mail-tester.com)

  3. Send an email from your Mailu account to that address

  4. Check the score on mail-tester.com

Expected results:

  • ✅ Score: 10/10

  • ✅ SPF: Pass (IP authorized in DNS)

  • ✅ DKIM: Pass (already configured)

  • ✅ DMARC: Pass (already configured)

  • ✅ Reverse DNS: Pass (PTR matches HELO hostname)

Troubleshooting

SPF Still Fails

Check DNS propagation:

dig +short TXT kup6s.com | grep spf

Verify the SPF record includes your egress IP.

Wrong Source IP in Email Headers

Verify egress policy is applied:

kubectl get ciliumegressgatewaypolicies.cilium.io mailu-egress -o yaml

Check that spec.egressIP matches your node’s IP.

PTR Record Doesn’t Match

Query authoritative DNS servers:

# Google DNS
dig @8.8.8.8 +short -x 5.75.247.168

# Cloudflare DNS
dig @1.1.1.1 +short -x 5.75.247.168

If these show mail.kup6s.com but your local resolver doesn’t, wait for cache expiration (TTL).

Maintenance

Node Replacement

If you need to replace the egress gateway node:

  1. Note the new node’s public IP

  2. Update cilium-egress-mailu.yaml with new IP

  3. Update SPF DNS record with new IP

  4. Update PTR records in Hetzner Console

  5. Wait for DNS propagation (1-5 minutes)

  6. Test with mail-tester.com

Adding More Nodes to Cluster

No changes needed - all Mailu traffic still routes through the designated egress gateway node.

See Also