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
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
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
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 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
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
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
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
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
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
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:
setup 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:
v=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!
NB: this article was originally published on oursi.net, my personal blog where I write about Kubernetes, self-hosting, and Linux.