DEV Community

Cover image for ForwardMX alternative: Email Forwarding with Kubernetes & LDAP
Benoît Vannesson
Benoît Vannesson

Posted on • Originally published at oursi.net

ForwardMX alternative: Email Forwarding with Kubernetes & LDAP

TL;DR: I replaced my $10/month forwardmx subscription with a self-hosted forwarding-only mail server setup, using OpenLDAP and docker-mailserver on my Kubernetes cluster. It took some tinkering, but now I receive and send emails from @oursi.net entirely from Gmail, for free 🎉


🧠 Context

When I bought oursi.net, I wanted to:

  • Receive emails like sample@oursi.net directly on my Gmail
  • Be able to send emails from that domain too
  • Avoid creating and managing actual mailboxes

Initially, I used forwardmx, which worked fine but cost nearly $10/month. That’s a lot for a few forwarding rules. So I decided to self-host everything on my own Kubernetes cluster.

My goal was:

  • A SMTP-only setup (no mailbox)
  • Full Gmail integration
  • Secure auth + SPF, DKIM, DMARC compliance

🧩 Tech Stack Overview

  • K3s Kubernetes cluster (self-hosted on VPS)
  • OpenLDAP for managing mail aliases & users
  • Docker-Mailserver (DMS) as the mail engine
  • MetalLB instead of ServiceLB for real IP forwarding
  • Traefik for routing (with Proxy Protocol v2)
  • Teleport to expose internal tools like LDAP UI

1. Deploying OpenLDAP via Helm

I used the jp-gouin/helm-openldap chart.

Why LDAP?

Because in SMTP-only mode, docker-mailserver works best with LDAP to:

  • Declare email aliases
  • Allow SMTP login
  • Define forwarding destinations

My ArgoCD app for OpenLDAP:

apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: ldap namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.io spec: project: default source: repoURL: https://jp-gouin.github.io/helm-openldap/ chart: openldap-stack-ha targetRevision: 4.3.3 helm: values: | replicaCount: 1 global: # Replace with your domain below ldapDomain: oursi.net  replication: # I disable replication because I don't need HA enabled: false customLdifFiles: 00-root.ldif: |- # Root creation, adapt to your domain dn: dc=oursi,dc=net objectClass: dcObject objectClass: organization o: Oursi.net 01-mailserver-member.ldif: |- # Creating the mailserver user, that will be used by postfix to connect to ldap. # The userPassword will need to be updated in phpldapadmin for instance dn: cn=mailserver,dc=oursi,dc=net objectClass: inetOrgPerson objectClass: top cn: mailserver sn: Postfix userPassword: {SSHA}x description: User for querying mail entries 02-mail-group.ldif: |- # Mail group creation, that's where I will define all the users for postfix dn: ou=mail,dc=oursi,dc=net objectClass: organizationalUnit objectClass: top ou: mail description: Mail organizational unit 03-benoit-user.ldif: |- # A sample postfix user,  dn: uid=sample@oursi.net,ou=mail,dc=oursi,dc=net objectClass: inetOrgPerson objectClass: top objectClass: postfixUser uid: sample@oursi.net cn: Sample User givenName: Sample sn: User mail: sample@oursi.net userPassword: {SSHA}x mailacceptinggeneralid: sample@oursi.net mailacceptinggeneralid: sample.user@oursi.net maildrop: sample.redirect@gmail.com # this is to grant access to mailserver to the list of users in mail customAcls: |- dn: olcDatabase={2}mdb,cn=config changetype: modify add: olcAccess olcAccess: to dn.subtree="ou=mail,dc=oursi,dc=net" by dn="cn=mailserver,dc=oursi,dc=net" read by * none  customSchemaFiles: #enable memberOf ldap search functionality, users automagically track groups they belong to 00-memberof.ldif: |- # Load memberof module dn: cn=module,cn=config cn: module objectClass: olcModuleList olcModuleLoad: memberof olcModulePath: /opt/bitnami/openldap/lib/openldap dn: olcOverlay=memberof,olcDatabase={2}mdb,cn=config changetype: add objectClass: olcOverlayConfig objectClass: olcMemberOf olcOverlay: memberof olcMemberOfRefint: TRUE 01-postfix.ldif: |- # Postfix creation: the users we create are also of class postfix, it allows for attributes maildrop and mailacceptinggeneralid dn: cn=postfix,cn=schema,cn=config cn: postfix objectclass: olcSchemaConfig olcattributetypes: {0}(1.3.6.1.4.1.4203.666.1.200 NAME 'mailacceptinggeneralid' DESC 'Postfix mail local address alias attribute' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024}) olcattributetypes: {1}(1.3.6.1.4.1.4203.666.1.201 NAME 'maildrop' DESC 'Postfix mail final destination attribute' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024}) olcobjectclasses: {0}(1.3.6.1.4.1.4203.666.1.100 NAME 'postfixUser' DESC 'Postfix mail user class' SUP top AUXILIARY MAY(mailacceptinggeneralid $ maildrop)) destination: server: https://kubernetes.default.svc namespace: ldap syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true 
Enter fullscreen mode Exit fullscreen mode

This also deploys:

  • phpldapadmin – web UI to browse LDAP
  • ltb-passwd – UI to reset LDAP user passwords

Teleport Exposure

Because I use teleport, I declare the new web services in my teleport kube agent config (see my previous article on teleport here)

- name: ldapadmin uri: "http://ldap-phpldapadmin.ldap.svc.cluster.local:80" public_addr: ldapadmin.teleport.oursi.net rewrite: redirect: - ldap-phpldapadmin.ldap.svc.cluster.local - name: ldappwd uri: "http://ldap-ltb-passwd.ldap.svc.cluster.local:80" public_addr: ldappwd.teleport.oursi.net rewrite: redirect: - ldap-ltb-passwd.ldap.svc.cluster.local 
Enter fullscreen mode Exit fullscreen mode

For teleport, I also need to update my Traefik TCP ingress route with new hosts:

- match: HostSNI(`ldapadmin.teleport.oursi.net`) services: - name: teleport port: 443 nativeLB: true - match: HostSNI(`ldappwd.teleport.oursi.net`) services: - name: teleport port: 443 nativeLB: true 
Enter fullscreen mode Exit fullscreen mode

After Deploying:

Go to phpldapadmin (via teleport for me) and log in with the default admin credentials (Not@SecurePassw0rd is the default, you should update it!). You should see this interface:

phpldapadmin.jpeg

Important user attributes are:

  • uid: the username for SMTP auth (e.g. sample@oursi.net)
  • mailacceptinggeneralid: one or more aliases
  • maildrop: where mail should go
  • userPassword: for SMTP auth

You should also check that you can connect with 'cn=mailserver,dc=oursi,dc=net' and that you can list users in the 'mail' group.

2. Setting Up docker-mailserver (DMS)

This was the trickiest part.

2.a. Switching to MetalLB

ServiceLB can’t preserve client IPs because it doesn’t operate in OSI Layer 2. That’s necessary to:

  • Preserve real IPs for logging and for spamming protection in DMS
  • Support Proxy Protocol for SMTP connections

So I reinstalled K3s without ServiceLB first, reusing my original installation command and altering that part of the command line:

--disable traefik,metrics-server,servicelb

Then I installed MetalLB via ArgoCD using this kustomization file

# kustomization.yml apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: metallb-system resources: - github.com/metallb/metallb/config/native?ref=v0.15.2 - pool.yaml 
Enter fullscreen mode Exit fullscreen mode

Here is the 'pool.yaml' file, you should adapt it with your available IP addresses:

apiVersion: metallb.io/v1beta1 kind: IPAddressPool metadata: name: first-pool namespace: metallb-system spec: addresses: - <your_ip> --- apiVersion: metallb.io/v1beta1 kind: L2Advertisement metadata: name: first-advertisement namespace: metallb-system 
Enter fullscreen mode Exit fullscreen mode

Check that exisiing services are still working properly and available online and let's move on to the next step.


2.b. Traefik EntryPoints + Proxy Protocol

By default, Traefik only listen to port 80 and 443 (web and websecure entrypoints respectively). We need to add new entrypoints by tweaking our values.yaml file (I install traefik using helm) and to set the external traffic policy to local.

service: spec: externalTrafficPolicy: Local # Preserve client IPs ports: smtp: port: 8025 # Container port expose: default: true # Expose through the default service exposedPort: 25 # Service port protocol: TCP # Port protocol (TCP/UDP) tls: enabled: false # TLS is not enabled for SMTP submissions: port: 8465 # Container port expose: default: true # Expose through the default service exposedPort: 465 # Service port protocol: TCP # Port protocol (TCP/UDP) tls: enabled: true submission: port: 8587 # Container port expose: default: true # Expose through the default service exposedPort: 587 # Service port protocol: TCP # Port protocol (TCP/UDP) tls: enabled: false 
Enter fullscreen mode Exit fullscreen mode

And we need to add some new TCP ingress routes as well, with proxy protocol enabled:

apiVersion: traefik.io/v1alpha1 kind: IngressRouteTCP metadata: name: submissions namespace: mailserver-oursi spec: entryPoints: - submissions routes: - match: HostSNI(`*`) services: - name: mailserver-docker-mailserver port: subs-proxy proxyProtocol: version: 2 --- apiVersion: traefik.io/v1alpha1 kind: IngressRouteTCP metadata: name: submission namespace: mailserver-oursi spec: entryPoints: - submission routes: - match: HostSNI(`*`) services: - name: mailserver-docker-mailserver port: sub-proxy proxyProtocol: version: 2 --- apiVersion: traefik.io/v1alpha1 kind: IngressRouteTCP metadata: name: smtp namespace: mailserver-oursi spec: entryPoints: - smtp routes: - match: HostSNI(`*`) services: - name: mailserver-docker-mailserver port: smtp-proxy proxyProtocol: version: 2 
Enter fullscreen mode Exit fullscreen mode

And we will also need a TLS certificate for 'mail.oursi.net':

apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: mail-tls-certificate-oursi spec: secretName: mail-tls-certificate-oursi isCA: false privateKey: algorithm: RSA encoding: PKCS1 size: 2048 dnsNames: [mail.oursi.net] issuerRef: name: letsencrypt-issuer # We can reference ClusterIssuers by changing the kind here. # The default value is Issuer (i.e. a locally namespaced Issuer) kind: ClusterIssuer # This is optional since cert-manager will default to this value however # if you are using an external issuer, change this to that issuer group. group: cert-manager.io 
Enter fullscreen mode Exit fullscreen mode

Now the network part should be ready, let's move on to the actual mail server.


3. Deploying DMS via ArgoCD

I use helm to deploy DMS, here is the ArgoCD yaml:

# filepath: argocd-apps/apps/teleport.yaml apiVersion: argoproj.io/v1alpha1 kind: Application metadata: name: mailserver namespace: argocd finalizers: - resources-finalizer.argocd.argoproj.io spec: project: default source: repoURL: https://docker-mailserver.github.io/docker-mailserver-helm chart: docker-mailserver targetRevision: 4.2.2 helm: values: | ## Specify the name of a TLS secret that contains a certificate and private key for your email domain. ## See https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets certificate: mail-tls-certificate-oursi deployment: env: LOG_LEVEL: info OVERRIDE_HOSTNAME: mail.oursi.net ACCOUNT_PROVISIONER: LDAP LDAP_START_TLS: 'yes' LDAP_SERVER_HOST: ldap://ldap.ldap.svc.cluster.local:389 LDAP_SEARCH_BASE: ou=mail,dc=oursi,dc=net LDAP_BIND_DN: cn=mailserver,dc=oursi,dc=net LDAP_BIND_PW: <mailserver password> SPOOF_PROTECTION: 1 ENABLE_SASLAUTHD: 1 SASLAUTHD_MECHANISMS: ldap SASLAUTHD_LDAP_SERVER: ldap://ldap.ldap.svc.cluster.local:389/ SASLAUTHD_LDAP_START_TLS: 'yes' SASLAUTHD_LDAP_BIND_DN: cn=mailserver,dc=oursi,dc=net SASLAUTHD_LDAP_PASSWORD: <mailserver password> SASLAUTHD_LDAP_SEARCH_BASE: ou=mail,dc=oursi,dc=net SASLAUTHD_LDAP_FILTER: (&(uid=%u@%r)(objectClass=postfixUser)) ENABLE_POP3:  ENABLE_CLAMAV: 0 SMTP_ONLY: 1 ENABLE_SPAMASSASSIN: 0 ENABLE_FETCHMAIL: 0 configMaps: user-patches.sh: create: true path: user-patches.sh data: | #!/bin/bash # NOTE: Keep in sync with upstream advice: # https://github.com/docker-mailserver/docker-mailserver/blob/v15.0.0/docs/content/examples/tutorials/mailserver-behind-proxy.md?plain=1#L238-L268 # Duplicate the config for the submission(s) service ports (587 / 465) with adjustments for the PROXY ports (10587 / 10465) and `syslog_name` setting: postconf -Mf submission/inet | sed -e s/^submission/10587/ -e 's/submission/submission-proxyprotocol/' >> /etc/postfix/master.cf postconf -Mf submissions/inet | sed -e s/^submissions/10465/ -e 's/submissions/submissions-proxyprotocol/' >> /etc/postfix/master.cf # Enable PROXY Protocol support for these new service variants: postconf -P 10587/inet/smtpd_upstream_proxy_protocol=haproxy postconf -P 10465/inet/smtpd_upstream_proxy_protocol=haproxy # Create a variant for port 25 too (NOTE: Port 10025 is already assigned in DMS to Amavis): postconf -Mf smtp/inet | sed -e s/^smtp/12525/ >> /etc/postfix/master.cf # Enable PROXY Protocol support (different setting as port 25 is handled via postscreen), optionally configure a `syslog_name` to distinguish in logs: postconf -P 12525/inet/postscreen_upstream_proxy_protocol=haproxy 12525/inet/postscreen_cache_map=proxy:btree:\$data_directory/postscreen_12525_cache 12525/inet/syslog_name=postfix/smtpd-proxyprotocol # This is necessary otherwise postscreen will fail when proxy mode is enabled: postconf -e "postscreen_cache_map = proxy:btree:/var/lib/postfix/postscreen_12525_cache" # Remove the default smtpd_sasl_local_domain setting ($mydomain) because I want to use the domain from the provided username # This allows me to support logins like sample@oursi.net and sample@vannesson.com both sed -i /etc/postfix/main.cf \ -e '/^smtpd_sasl_local_domain/d' rm -f /etc/postfix/{ldap-groups.cf,ldap-domains.cf} postconf \ "virtual_mailbox_domains = /etc/postfix/vhost" \ "virtual_alias_maps = ldap:/etc/postfix/ldap-aliases.cf texthash:/etc/postfix/virtual" \ "smtpd_sender_login_maps = ldap:/etc/postfix/ldap-users.cf" sed -i /etc/postfix/ldap-users.cf \ -e '/query_filter/d' \ -e '/result_attribute/d' \ -e '/result_format/d' cat <<EOF >> /etc/postfix/ldap-users.cf query_filter = (&(mailacceptinggeneralid=%s)(objectClass=postfixUser)) result_attribute = uid EOF sed -i /etc/postfix/ldap-aliases.cf \ -e '/domain/d' \ -e '/query_filter/d' \ -e '/result_attribute/d' cat <<EOF >> /etc/postfix/ldap-aliases.cf domain = oursi.net, vannesson.com query_filter = (&(mailacceptinggeneralid=%s)(objectClass=postfixUser)) result_attribute = maildrop EOF sed -i /etc/postfix/ldap-senders.cf \ -e '/start_tls/d'  cat <<EOF >> /etc/postfix/ldap-senders.cf start_tls = yes EOF echo vannesson.com >> /etc/postfix/vhost destination: server: https://kubernetes.default.svc namespace: mailserver-oursi syncPolicy: automated: prune: true selfHeal: true syncOptions: - CreateNamespace=true 
Enter fullscreen mode Exit fullscreen mode

Key Config:

  • Uses the TLS cert from cert-manager (mail.oursi.net)
  • Auth via LDAP
  • Custom user-patches.sh script to:
    • Add support for Proxy Protocol
    • Update postfix LDAP configs
    • Handle multiple domains (oursi.net + vannesson.com)

This script was tricky but essential. With that, DMS should be operational. We just need to finalize some DNS configuration so that other mail servers know about us and trust us.


4. DNS Setup

✉️ MX Records

For both oursi.net and vannesson.com, I set:

MX 10 mail.oursi.net 
Enter fullscreen mode Exit fullscreen mode

This will allow other mail servers to know where to connect to deliver mail to us.

✅ SPF

TXT record on oursi.net and mail.oursi.net:

v=spf1 a:mail.oursi.net include:_spf.google.com -all 
Enter fullscreen mode Exit fullscreen mode

SPF is used to specify which mail servers are authorized to send emails on behalf of a domain.

🔒 DMARC

DMARC is an email authentication protocol that builds on SPF and DKIM to let domain owners specify how to handle unauthenticated messages and receive reports about email activity.

TXT record on _dmarc.oursi.net:

v=DMARC1; p=reject; rua=mailto:dmarc@oursi.net; ruf=mailto:dmarc@oursi.net; fo=1; pct=100; 
Enter fullscreen mode Exit fullscreen mode

Make sure the address you set for rua and ruf are redirected somewhere (using ldap of course 😊).

🖋 DKIM

Inside DMS pod:

setup config dkim keysize 2048 domain oursi.net 
Enter fullscreen mode Exit fullscreen mode

Then copy the generated TXT record and apply it to _mail._domainkey.oursi.net.

It looks like this:

v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzuBYS9ZsMLwI7lDXYzGxUTyJs8IOYUm2siwfNdHjlaWvLHKS48kiS/r99A8Lr94VI+DcRblVgykbOjJHRhu0D5jeXrHGdbljRRC6Ym6VKDsmzBOSikG6rdDFOucr+RFK9bsnV/51TiMf82TsVSHNs8LOeVkFxOP4eoBeGGM6Mj5NmxJuG9iF+jKVW08NGQ22Bd/7dL17xxKFuO5TWvuqAbYMxLa2ZP6WyaoO7b5KSWCbE76NFKwO81/sgOHeW8hqqiRpscRA5w4yRd10mvRP+cw8cqeRy1QcBRtVIlfq5dTcvIq9OJ6RCQoRtA96x/bh1vnaZPufqAYbrw3P95905QIDAQAB 
Enter fullscreen mode Exit fullscreen mode

This is actually a public key that will be used to validate the signature of messages sent by postfix.


5. Verifying the Setup

Test with:

  • Sending/receiving mail to aliases
  • Configure GMAIL to be able to send mails from your domain (you will have to enter your mail server address, 'mail.oursi.net' for me, alongside username and password).

Everything should route to your Gmail now 🎉


💌 Conclusion

Now I have:

  • Zero-cost email forwarding
  • SMTP support to send from Gmail
  • Custom domain branding
  • Fully compliant SPF/DKIM/DMARC config

All self-hosted, secure and tweakable. If you’re tired of paying for simple email forwarding, give this a go!


Enjoy!

NB: this article was originally published on oursi.net, my personal blog where I write about Kubernetes, self-hosting, and Linux.

Top comments (0)