
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 LDAPltb-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:
Important user attributes are:
uid
: the username for SMTP auth (e.g.sample@oursi.net
)mailacceptinggeneralid
: one or more aliasesmaildrop
: where mail should gouserPassword
: 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!