No alternative text provided

Ditching ForwardMX: 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 helm 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:

Be prepared, it's a heavy one!

1apiVersion: argoproj.io/v1alpha1
2kind: Application
3metadata:
4  name: ldap
5  namespace: argocd
6  finalizers:
7    - resources-finalizer.argocd.argoproj.io
8spec:
9  project: default
10  source:
11    repoURL: https://jp-gouin.github.io/helm-openldap/
12    chart: openldap-stack-ha
13    targetRevision: 4.3.3
14    helm:
15      values: |
16        replicaCount: 1
17        global:
18          # Replace with your domain below
19          ldapDomain: oursi.net 
20        replication:
21          # I disable replication because I don't need HA
22          enabled: false
23          
24        ltb-passwd:
25          ingress:
26            enabled: false
27
28          ldap:
29            # This will restrict password update for mail forwarding users only
30            searchBase: "ou=mail,dc=oursi,dc=net"
31
32        phpldapadmin:
33            ingress:
34              enabled: false
35
36        customLdifFiles:
37          00-root.ldif: |-
38            # Root creation, adapt to your domain
39            dn: dc=oursi,dc=net
40            objectClass: dcObject
41            objectClass: organization
42            o: Oursi.net
43
44          01-mailserver-member.ldif: |-
45            # Creating the mailserver user, that will be used by postfix to connect to ldap.
46            # The userPassword will need to be updated in phpldapadmin for instance
47            dn: cn=mailserver,dc=oursi,dc=net
48            objectClass: inetOrgPerson
49            objectClass: top
50            cn: mailserver
51            sn: Postfix
52            userPassword: {SSHA}x
53            description: User for querying mail entries
54
55          02-mail-group.ldif: |-
56            # Mail group creation, that's where I will define all the users for postfix
57            dn: ou=mail,dc=oursi,dc=net
58            objectClass: organizationalUnit
59            objectClass: top
60            ou: mail
61            description: Mail organizational unit
62
63          03-benoit-user.ldif: |-
64            # A sample postfix user, 
65            dn: uid=sample@oursi.net,ou=mail,dc=oursi,dc=net
66            objectClass: inetOrgPerson
67            objectClass: postfixUser
68            objectClass: top
69            uid: sample@oursi.net
70            cn: Sample User
71            givenName: Sample
72            sn: User
73            userPassword: {SSHA}x
74            mailacceptinggeneralid: sample@oursi.net
75            mailacceptinggeneralid: sample.user@oursi.net
76            maildrop: sample.redirect@gmail.com
77
78        # this is to grant access to mailserver to the list of users in mail
79        customAcls: |-
80           dn: olcDatabase={2}mdb,cn=config
81           changetype: modify
82           replace: olcAccess
83           olcAccess: {0}to *
84              by dn.exact=gidNumber=0+uidNumber=1001,cn=peercred,cn=external,cn=auth manage
85              by * break
86           olcAccess: {1}to dn.subtree="ou=mail,dc=oursi,dc=net" 
87              by dn="cn=mailserver,dc=oursi,dc=net" read
88              by self read
89              by * break 
90           olcAccess: {2}to attrs=userPassword,shadowLastChange
91              by self write
92              by dn="cn=admin,dc=oursi,dc=net" write
93              by dn="cn=mailserver,dc=oursi,dc=net" read
94              by anonymous auth 
95              by * break
96           olcAccess: {3}to *
97              by dn="cn=admin,dc=oursi,dc=net" write
98              by self read
99              by * break
100              
101        customSchemaFiles:
102          #enable memberOf ldap search functionality, users automagically track groups they belong to
103          00-memberof.ldif: |-
104            # Load memberof module
105            dn: cn=module,cn=config
106            cn: module
107            objectClass: olcModuleList
108            olcModuleLoad: memberof
109            olcModulePath: /opt/bitnami/openldap/lib/openldap
110
111            dn: olcOverlay=memberof,olcDatabase={2}mdb,cn=config
112            changetype: add
113            objectClass: olcOverlayConfig
114            objectClass: olcMemberOf
115            olcOverlay: memberof
116            olcMemberOfRefint: TRUE
117
118          01-postfix.ldif: |-
119            # Postfix creation: the users we create are also of class postfix, it allows for attributes maildrop and mailacceptinggeneralid
120            dn: cn=postfix,cn=schema,cn=config
121            cn: postfix
122            objectclass: olcSchemaConfig
123            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})
124            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})
125            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))
126
127  destination:
128    server: https://kubernetes.default.svc
129    namespace: ldap
130  syncPolicy:
131    automated:
132      prune: true
133      selfHeal: true
134    syncOptions:
135      - CreateNamespace=true

This also deploys:

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

There are a lot of parameters there, basically I prepare the main objects I will be using with ldap:

  • the root OrganisationalUnit
  • a mailserver user that will be used by Postfix to check existing accounts
  • a mail group where I will store Postfix users
  • a PostfixUser object class that adds the "mailacceptinggeneralid" and "maildrop" attributes
  • a sample user for testing purposes.

NB: I dit not override the default admin passwords here, and I set an invalid password for the mailserver account. These will need to be updated for production, I'll cover that in a future article.

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). This will allow me to access the admin without port forwarding.

1- name: ldapadmin
2  uri: "http://ldap-phpldapadmin.ldap.svc.cluster.local:80"
3  public_addr: ldapadmin.teleport.oursi.net
4  rewrite:
5    redirect:
6      - ldap-phpldapadmin.ldap.svc.cluster.local
7- name: ldappwd
8  uri: "http://ldap-ltb-passwd.ldap.svc.cluster.local:80"
9  public_addr: ldappwd.teleport.oursi.net
10  rewrite:
11    redirect:
12      - ldap-ltb-passwd.ldap.svc.cluster.local

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

1- match: HostSNI(`ldapadmin.teleport.oursi.net`)
2  services:
3    - name: teleport
4      port: 443
5      nativeLB: true
6- match: HostSNI(`ldappwd.teleport.oursi.net`)
7  services:
8    - name: teleport
9      port: 443
10      nativeLB: true

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 update the password of cn=mailserver using the interface (the password we set up in the helm chart is not valid).


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

You can do that by executing a shell command in the ldap-0 pod:

1ldapsearch -x -H ldap://ldap.ldap.svc.cluster.local -D "cn=mailserver,dc=oursi,dc=net" -W -b "ou=mail,dc=oursi,dc=net" "(objectClass=inetOrgPerson)"

Use your newly set password, it should return the sample user.

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

1# kustomization.yml
2apiVersion: kustomize.config.k8s.io/v1beta1
3kind: Kustomization
4namespace: metallb-system
5
6resources:
7  - github.com/metallb/metallb/config/native?ref=v0.15.2
8  - pool.yaml

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

1apiVersion: metallb.io/v1beta1
2kind: IPAddressPool
3metadata:
4  name: first-pool
5  namespace: metallb-system
6spec:
7  addresses:
8    - <your_ip>
9---
10apiVersion: metallb.io/v1beta1
11kind: L2Advertisement
12metadata:
13  name: first-advertisement
14  namespace: metallb-system

Check that existing 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.

1service:
2  spec:
3    externalTrafficPolicy: Local # Preserve client IPs
4ports:
5  smtp:
6    port: 8025                # Container port
7    expose:
8      default: true           # Expose through the default service
9    exposedPort: 25           # Service port
10    protocol: TCP             # Port protocol (TCP/UDP)
11    tls:
12      enabled: false          # TLS is not enabled for SMTP
13  submissions:
14    port: 8465                # Container port
15    expose:
16      default: true           # Expose through the default service
17    exposedPort: 465          # Service port
18    protocol: TCP             # Port protocol (TCP/UDP)
19    tls:
20      enabled: true
21  submission:
22    port: 8587                # Container port
23    expose:
24      default: true           # Expose through the default service
25    exposedPort: 587          # Service port
26    protocol: TCP             # Port protocol (TCP/UDP)
27    tls:
28      enabled: false

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

1apiVersion: traefik.io/v1alpha1
2kind: IngressRouteTCP
3metadata:
4  name: submissions
5  namespace: mailserver-oursi
6spec:
7  entryPoints:
8    - submissions
9  routes:
10    - match: HostSNI(`*`)
11      services:
12        - name: mailserver-docker-mailserver
13          port: subs-proxy
14          proxyProtocol:
15            version: 2
16---
17apiVersion: traefik.io/v1alpha1
18kind: IngressRouteTCP
19metadata:
20  name: submission
21  namespace: mailserver-oursi
22spec:
23  entryPoints:
24    - submission
25  routes:
26    - match: HostSNI(`*`)
27      services:
28        - name: mailserver-docker-mailserver
29          port: sub-proxy
30          proxyProtocol:
31            version: 2
32---
33apiVersion: traefik.io/v1alpha1
34kind: IngressRouteTCP
35metadata:
36  name: smtp
37  namespace: mailserver-oursi
38spec:
39  entryPoints:
40    - smtp
41  routes:
42    - match: HostSNI(`*`)
43      services:
44        - name: mailserver-docker-mailserver
45          port: smtp-proxy
46          proxyProtocol:
47            version: 2

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

1apiVersion: cert-manager.io/v1
2kind: Certificate
3
4metadata:
5  name: mail-tls-certificate-oursi
6
7spec:
8  secretName: mail-tls-certificate-oursi
9  isCA: false
10  privateKey:
11    algorithm: RSA
12    encoding: PKCS1
13    size: 2048
14  dnsNames: [mail.oursi.net]
15  issuerRef:
16    name: letsencrypt-issuer
17    # We can reference ClusterIssuers by changing the kind here.
18    # The default value is Issuer (i.e. a locally namespaced Issuer)
19    kind: ClusterIssuer
20    # This is optional since cert-manager will default to this value however
21    # if you are using an external issuer, change this to that issuer group.
22    group: cert-manager.io

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:

1# filepath: argocd-apps/apps/teleport.yaml
2apiVersion: argoproj.io/v1alpha1
3kind: Application
4metadata:
5  name: mailserver
6  namespace: argocd
7  finalizers:
8    - resources-finalizer.argocd.argoproj.io
9spec:
10  project: default
11  source:
12    repoURL: https://docker-mailserver.github.io/docker-mailserver-helm
13    chart: docker-mailserver
14    targetRevision: 4.2.2
15    helm:
16      values: |
17        ## Specify the name of a TLS secret that contains a certificate and private key for your email domain.
18        ## See https://kubernetes.io/docs/concepts/configuration/secret/#tls-secrets
19        certificate: mail-tls-certificate-oursi
20
21        deployment:
22          env:
23            LOG_LEVEL: info
24            OVERRIDE_HOSTNAME: mail.oursi.net
25
26            ACCOUNT_PROVISIONER: LDAP
27            LDAP_START_TLS: 'yes'
28            LDAP_SERVER_HOST: ldap://ldap.ldap.svc.cluster.local:389
29            LDAP_SEARCH_BASE: ou=mail,dc=oursi,dc=net
30            LDAP_BIND_DN: cn=mailserver,dc=oursi,dc=net
31            LDAP_BIND_PW: <mailserver password>
32            SPOOF_PROTECTION: 1
33
34            ENABLE_SASLAUTHD: 1
35            SASLAUTHD_MECHANISMS: ldap
36            SASLAUTHD_LDAP_SERVER: ldap://ldap.ldap.svc.cluster.local:389/
37            SASLAUTHD_LDAP_START_TLS: 'yes'
38            SASLAUTHD_LDAP_BIND_DN: cn=mailserver,dc=oursi,dc=net
39            SASLAUTHD_LDAP_PASSWORD: <mailserver password>
40            SASLAUTHD_LDAP_SEARCH_BASE: ou=mail,dc=oursi,dc=net
41            SASLAUTHD_LDAP_FILTER: (&(uid=%u@%r)(objectClass=postfixUser))
42
43            ENABLE_POP3: 
44            ENABLE_CLAMAV: 0
45            SMTP_ONLY: 1
46            ENABLE_SPAMASSASSIN: 0
47            ENABLE_FETCHMAIL: 0
48
49        configMaps:
50          user-patches.sh:
51            create: true
52            path: user-patches.sh
53            data: |
54              #!/bin/bash
55
56              # NOTE: Keep in sync with upstream advice:
57              # https://github.com/docker-mailserver/docker-mailserver/blob/v15.0.0/docs/content/examples/tutorials/mailserver-behind-proxy.md?plain=1#L238-L268
58
59              # Duplicate the config for the submission(s) service ports (587 / 465) with adjustments for the PROXY ports (10587 / 10465) and `syslog_name` setting:
60              postconf -Mf submission/inet | sed -e s/^submission/10587/ -e 's/submission/submission-proxyprotocol/' >> /etc/postfix/master.cf
61              postconf -Mf submissions/inet | sed -e s/^submissions/10465/ -e 's/submissions/submissions-proxyprotocol/' >> /etc/postfix/master.cf
62              # Enable PROXY Protocol support for these new service variants:
63              postconf -P 10587/inet/smtpd_upstream_proxy_protocol=haproxy
64              postconf -P 10465/inet/smtpd_upstream_proxy_protocol=haproxy
65
66              # Create a variant for port 25 too (NOTE: Port 10025 is already assigned in DMS to Amavis):
67              postconf -Mf smtp/inet | sed -e s/^smtp/12525/ >> /etc/postfix/master.cf
68              # Enable PROXY Protocol support (different setting as port 25 is handled via postscreen), optionally configure a `syslog_name` to distinguish in logs:
69              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
70              # This is necessary otherwise postscreen will fail when proxy mode is enabled:
71              postconf -e "postscreen_cache_map = proxy:btree:/var/lib/postfix/postscreen_12525_cache"
72
73              # Remove the default smtpd_sasl_local_domain setting ($mydomain) because I want to use the domain from the provided username
74              # This allows me to support logins like sample@oursi.net and sample@vannesson.com both
75              sed -i /etc/postfix/main.cf \
76                  -e '/^smtpd_sasl_local_domain/d'
77
78              rm -f /etc/postfix/{ldap-groups.cf,ldap-domains.cf}
79
80              postconf \
81                  "virtual_mailbox_domains = /etc/postfix/vhost" \
82                  "virtual_alias_maps = ldap:/etc/postfix/ldap-aliases.cf texthash:/etc/postfix/virtual" \
83                  "smtpd_sender_login_maps = ldap:/etc/postfix/ldap-users.cf"
84
85              sed -i /etc/postfix/ldap-users.cf \
86                  -e '/query_filter/d' \
87                  -e '/result_attribute/d' \
88                  -e '/result_format/d'
89              cat <<EOF >> /etc/postfix/ldap-users.cf
90              query_filter = (&(mailacceptinggeneralid=%s)(objectClass=postfixUser))
91              result_attribute = uid
92              EOF
93
94              sed -i /etc/postfix/ldap-aliases.cf \
95                  -e '/domain/d' \
96                  -e '/query_filter/d' \
97                  -e '/result_attribute/d'
98              cat <<EOF >> /etc/postfix/ldap-aliases.cf
99              domain = oursi.net, vannesson.com
100              query_filter = (&(mailacceptinggeneralid=%s)(objectClass=postfixUser))
101              result_attribute = maildrop
102              EOF
103
104              sed -i /etc/postfix/ldap-senders.cf \
105                  -e '/start_tls/d' 
106              cat <<EOF >> /etc/postfix/ldap-senders.cf
107              start_tls = yes
108              EOF
109              
110              echo vannesson.com >> /etc/postfix/vhost
111
112  destination:
113    server: https://kubernetes.default.svc
114    namespace: mailserver-oursi
115  syncPolicy:
116    automated:
117      prune: true
118      selfHeal: true
119    syncOptions:
120      - CreateNamespace=true

You should replace the mailserver password with the password you updated in the ldap admin interface.

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

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

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;

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

πŸ–‹ DKIM

Inside DMS pod:

1setup config dkim keysize 2048 domain oursi.net

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

It looks like this:

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

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!