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:
Applies terraform changes
Waits for Cilium to upgrade with egress gateway feature
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:
Go to Hetzner Cloud Console → Servers
Click on your egress gateway server (e.g.,
kup6s-control-fsn1-jpq)Navigate to Networking tab
Find Reverse DNS section
For IPv4 (e.g., 5.75.247.168):
Click edit → Enter
mail.kup6s.com→ Save
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 recordsa:mail.kup6s.com- Authorize A/AAAA records for mail.kup6s.comip4:5.75.247.168- Explicitly authorize egress IPv4ip6:2a01:4f8:c012:f3f0::1- Explicitly authorize egress IPv6~all- Soft fail for other IPs (recommended over-allhard 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:
Go to https://www.mail-tester.com
Copy the test email address (e.g.,
test-abc123@srv1.mail-tester.com)Send an email from your Mailu account to that address
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:
Note the new node’s public IP
Update
cilium-egress-mailu.yamlwith new IPUpdate SPF DNS record with new IP
Update PTR records in Hetzner Console
Wait for DNS propagation (1-5 minutes)
Test with mail-tester.com
Adding More Nodes to Cluster¶
No changes needed - all Mailu traffic still routes through the designated egress gateway node.