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

Benoît Vannesson @oursinet

About: Hands on Engineering Manager and owner of oursi.net

Location:
Paris, France
Joined:
Jun 27, 2025

ForwardMX alternative: Email Forwarding with Kubernetes & LDAP

Publish Date: Jun 30
0 0

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.

Comments 0 total

    Add comment