Building My Self-Hosted Mail Server with Mailcow-Dockerized
How I turned 'I should run my own email' into a two-node, MAAS-provisioned, Ansible-deployed, cold-spare mailcow cluster - because nothing says 'simple' like a 24GB RAM budget for sending the occasional newsletter.
Running your own mail server is the homelab equivalent of free solo climbing: deeply unwise, occasionally exhilarating, and a fantastic way to learn exactly how many things can go wrong at once. Naturally, I had to try again - and naturally, I could not leave it as a single VM like a normal person.
This isn’t my first time at the rodeo, either. Years ago I cut my teeth on Mercury Mail and Roundcube running on XAMPP - on a Toshiba laptop, no less - before graduating to eBox (these days it goes by Zentyal), and in my day job I’ve run Microsoft Exchange in production - yes, the real thing, the one with the database that develops opinions. So much fun. If anyone actually finds this blog and asks nicely, maybe I’ll spin Exchange up for giggles and write that one up too. You’ve been warned.
What follows is the real build: two Proxmox VMs, bare-metal-style provisioning via MAAS, an Ansible playbook doing the heavy lifting, mailcow-dockerized on top, and a cold spare standing by for the inevitable. This is “self-hosted email” as interpreted by someone who has never met a problem they couldn’t over-engineer.
One thing this is not: a guide. This is me rambling, in public, because I apparently enjoy publishing my own self-deprecation. You’re more than welcome to follow along and build the same thing at home - but if you actually want a guide, go read mailcow’s documentation; it’s excellent and it’s written by people who know what they’re doing. And if you’d rather have someone just tell you how to do it - or do it for you outright - I accept PayPal.
Why bother?
Fair question. The honest answers:
- It’s “free.” Air-quotes doing heavy lifting there. The software is free; the RAM, electricity, time, and slow erosion of my sanity are billed separately and at a premium.
- My friends need mailboxes. Yes, I have friends - at least one - and some of them need addresses for apps and various one-off projects that demand a real mailbox. I am now, apparently, a tiny email provider with a customer base I can count on one hand.
- Maybe, someday, I’ll practice what I preach. I spend a lot of breath telling people to own their data and then quietly check my Microslop 365 and Gmail like a hypocrite. This is step one toward eventually de-clouding myself. Step two through twelve remain theoretical.
So: equal parts principle, peer pressure, and the irresistible urge to over-build something that a $6/month mailbox would handle just fine.
Gotchas before you start
Before you get any ideas, here’s the stuff that quietly sinks most self-hosted mail projects. Read this part twice:
- You need a real domain and DNS. This is non-negotiable. Yes, mail can technically run on a bare IP address, and no, nobody on the public internet will accept it. If you’re building a closed, private network where every party explicitly verifies every other party, then sure, knock yourself out. For everyone else: get a domain. I run mine through Cloudflare, largely because it bolts on a pile of nice integrations (DNS-based certificate validation chief among them) that make the rest of this build smoother. Other providers offer similar features and will work just as well; Cloudflare is simply the one that’s never let me down so far.
- You need a static IP. Mail and dynamic addressing do not mix. Receiving servers are deeply suspicious of anything that looks residential or ephemeral.
- Residential ISPs love to block port 25. Inbound SMTP - the port the entire internet uses to deliver mail to you - is frequently blocked on residential connections. No port 25, no incoming mail, full stop.
- Residential IP ranges are widely blacklisted. Even if your ISP unblocks 25, large chunks of residential space sit on spam blocklists by default. You’ll be fighting an uphill deliverability battle from day one.
- Cheap VPS providers are often blacklisted too. This isn’t just a residential problem. Plenty of budget VPS providers have had their IP ranges torched by years of spam abuse from other customers, so you inherit a reputation you never earned.
If you actually want to do this yourself, start by reviewing the providers that mailcow vets for clean IP space and unblocked ports: mailcow.email/vps. Picking from that list saves you an enormous amount of blocklist-removal grief.
And if you’d rather skip the research entirely - you can reach me on Discord (thefathacker). I can potentially point you toward the VPS and colocation provider I use to colo my own servers - the very ones this whole setup runs from.
The architecture (or: how to spend 24GB of RAM on email)
The plan was two nodes living in a DMZ VLAN on Proxmox:
- MAIL-SR-1 - the primary. 4 cores, 16GB RAM, 200GB disk, plus a separate 32GB volume for
/var/logbecause I don’t trust myself - or any service I run - not to fill a disk. Isolating logs means a runaway logger fills its own little 32GB sandbox instead of swallowing the root filesystem and taking the whole box down with it. - MAIL-SR-2 - the cold spare. Half the resources (2 cores, 8GB), same disk layout, sitting quietly waiting for primary to do something stupid.
Both run Ubuntu 26.04 LTS, both live behind the firewall in the DMZ, and both exist purely so I can say the word “redundancy” with a straight face.
One honest note before moving on: you do not need the hardware I’m throwing at this, and hardware “requirements” depend entirely on your situation. Two VMs, 24GB of RAM, a cold spare, and a small mountain of supporting services is comically overkill for most people - mailcow’s own system prerequisites make clear it’ll happily run on far less. If you just want working email for yourself and a couple of friends, a single modest VM does the job fine; everything past that point here is me over-engineering for fun, so take the bits you need and leave the gold-plating to me. The kind of hardware matters too. These VMs sit on a Ceph SSD cluster, so the storage underneath is fast and the numbers above reflect that. Run the same stack off spinning HDDs, a microSD card (yes, you can do this on a Raspberry Pi - “can” and “should” being different words), or a DRAM-less SSD, and the performance profile shifts underneath you. RAM and core count, meanwhile, mostly drive how many users can actively connect and do work at once, not whether it runs at all. And don’t assume slow disks doom you, either: you’d be surprised how well Exchange runs on a $100k Nimble array full of hard drives - once you’ve got enough spindles and a fat caching layer sitting on top. The lesson isn’t “buy SSDs,” it’s “know your bottleneck.” Match the iron to the workload and the number of humans actually using it.
Provisioning with MAAS + Proxmox
Rather than click through an Ubuntu installer twice like an animal, the VMs are provisioned by MAAS, with Proxmox as the power driver. MAAS talks to Proxmox over API tokens (one per VM) so it can power-cycle and deploy the nodes on demand.
The one operational lesson here, learned the hard way: when Proxmox shows you an API token secret, it shows it to you exactly once. My notes on the token secret read, verbatim, “USED ONCE - THEN FORGOT.” This is fine. This is a feature. Regenerating tokens builds character.
Call it a security posture: if I don’t need to remember a password, I won’t. A secret that’s never written down can’t be leaked, so I don’t write them down. Inconvenient? Occasionally. Breached? Not via my notes.
Deployment with Ansible
Is this overkill? Almost certainly. Why spend ten minutes doing the task by hand when you could spend four hours writing the playbook instead? But I do it anyway, because the payoff is repeatability. The same playbook that builds a node from scratch also keeps the core OS components updated, and lets me change things like my backup target by editing a single parameter. Those parameters live in a private Git repo, so any change is version-controlled and rolls out cleanly - no SSHing in and editing files by hand, no wondering what I changed at 2 AM. most of the time
The boring-but-correct part. A single playbook (deploy-mailcow.yml) brings a freshly-MAAS’d node from “blank Ubuntu” to “ready for mailcow” using a stack of roles, along with handling package updates:
common- qemu-guest-agent so Proxmox stops lying about the VM’s IPdocker- the actual enginenetplan_static- static addressing, and crucially disabling IPv6 DHCP/RA so the host stops fighting me over its own addresscifs_mount- mounts a backup share off the TrueNAS box at/opt/backupstep_certificate+zabbix_agent- monitoring, because if it isn’t graphed, it didn’t happen- rsync, installed and updated, for the cold-spare sync later
Backups land on a dedicated CIFS share, monitoring reports into a Zabbix 7.4 cluster, and I get to pretend this is a real production environment.
If you’re sensibly not doing all this and just want the bare minimum to get mailcow running on a host, you really only need two things installed:
- Docker Engine - the whole stack runs on it.
- rsync - for cold-spare sync and/or remote backups, if you’re not backing up to external storage.
Everything else above - MAAS, Zabbix, CIFS mounts, certificate roles - is me gold-plating a mail server. None of it is required to send and receive email.
Installing mailcow
With the host prepped, mailcow itself is refreshingly civilized:
sudo -s
umask 0022
cd /opt
git clone https://github.com/mailcow/mailcow-dockerized
cd mailcow-dockerized
./generate_config.sh
The config generator asks for your mail hostname (mail.thefathacker.tech here - not your mail domain, a distinction that trips up everyone exactly once), a timezone, and a branch (stable master, obviously). It even checks whether your IP is on the Spamhaus Bad ASN list before you get your hopes up. Mine came back clean, which felt like an undeserved compliment.
It also offered to create /etc/docker/daemon.json with IPv6 settings and restart Docker. I said yes, because what’s the worst that could happen to a mail server’s networking.
Tweaking mailcow.conf
A few changes before first start:
# Extra cert SANs for imap.* and smtp.*
ADDITIONAL_SAN=imap.*,smtp.*
# Let's Encrypt via Cloudflare DNS-01 challenge
ACME_DNS_CHALLENGE=y
ACME_DNS_PROVIDER=dns_cf
ACME_ACCOUNT_EMAIL=[email protected]
# Watchdog notifications
WATCHDOG_NOTIFY_EMAIL=[email protected]
WATCHDOG_NOTIFY_BAN=y
WATCHDOG_SUBJECT=The Fat Hacker - Mail Watchdog ALERT
WATCHDOG_EXTERNAL_CHECKS=y
The DNS-01 challenge via Cloudflare is the key move: it means Let’s Encrypt validates certs through DNS instead of needing port 80 reachable, which plays nicely with my reverse-proxy setup. Enabling it is documented in the mailcow SSL/DNS guide - the ACME_* lines above turn it on.
Now you might reasonably ask: if I’m putting Traefik in front of all this (spoiler - I am, more on that later), why does mailcow need its own certs at all? Because Traefik only terminates TLS for the web side. The mail protocols - IMAPS, POP3S, SMTPS - connect straight to mailcow, and clients flatly refuse to talk to them without a valid certificate. Try a self-signed cert on an iPhone sometime and watch how thrilled it is to set up your mailbox. So mailcow gets real, auto-renewing Let’s Encrypt certs of its own, Traefik or no Traefik.
The Cloudflare credentials themselves live in data/conf/acme/dns-01.conf, following the acme.sh dns_cf reference:
CF_Token= # https://dash.cloudflare.com/profile/api-tokens
CF_Account_ID= # Account ID is on the right of any zone's "Overview" page when using CloudFlare
Use a scoped API token (DNS edit on just the relevant zones), not your global API key - handing a container god-mode over your whole Cloudflare account is how horror stories start.
DMARC reporting
I wanted real DMARC aggregate reports, so Rspamd got configured to send them on behalf of the domain. The reporting config goes in data/conf/rspamd/local.d/dmarc.conf:
reporting {
enabled = true;
email = '[email protected]';
bcc_addrs = ["[email protected]"];
domain = 'thefathacker.tech';
org_name = 'The Fat Hacker';
helo = 'rspamd';
smtp = 'postfix';
smtp_port = 25;
from_name = 'The Fat Hacker DMARC Report';
msgid_from = 'rspamd.mail.thefathacker.tech';
max_entries = 2k;
keys_expire = 2d;
}
Reports don’t generate themselves, though - that needs a scheduled task. mailcow ships Ofelia for exactly this, driven by container labels in a docker-compose.override.yml. Nothing secret lives in this file, so here’s the whole thing:
services:
rspamd-mailcow:
environment:
- MASTER=${MASTER:-y}
labels:
ofelia.enabled: "true"
ofelia.job-exec.rspamd_dmarc_reporting_yesterday.schedule: "@every 24h"
ofelia.job-exec.rspamd_dmarc_reporting_yesterday.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/bin/rspamadm dmarc_report $(date --date yesterday '+%Y%m%d') > /var/lib/rspamd/dmarc_reports_last_log 2>&1 || exit 0\""
ofelia-mailcow:
depends_on:
- rspamd-mailcow
If the stack is already running, docker compose up -d applies the override. Now I receive XML reports telling me exactly who’s trying to spoof me. Riveting weekend reading - assuming you enjoy parsing raw XML by eyeball, which I do not.
One day (the load-bearing words of every homelab) I intend to deploy parsedmarc to ingest these reports and turn them into dashboards a human can actually read. One day.
First start
docker compose pull
docker compose up -d
A few minutes later, a dozen-plus containers are up - Postfix, Dovecot, Rspamd, SOGo, ClamAV, Redis, MariaDB - and the admin UI answers at https://mail.thefathacker.tech. Log in with mailcow’s well-known defaults - username admin, password moohoo - and then change them immediately, a step I will not be elaborating on for reasons of dignity.
And to be crystal clear: change them before anything is publicly reachable. Leave default credentials on a public-facing box and you can confidently expect to no longer own that server in a couple of hours - at which point your only move is to nuke it and start over. While you’re in there, turn on 2FA too; it’s not optional in my book. Ask me how I know. (Shudder.) Let’s just say a MikroTik on a VPS with default creds is a fascinating experiment in how fast the internet finds you, and leave it there.
Creating your first domain
With the admin account locked down, the next stop is the mailbox admin console to add a domain. Mine was fathaxz.wtf; yours will be whatever you’re hosting.
Resist the urge to fiddle here. For the first pass, leave every setting at its default - quotas, mailbox limits, all of it. You can tune those later once mail is actually flowing. The one thing you genuinely need out of this step is the DKIM record: when you create the domain, mailcow generates a DKIM keypair for it. Grab the public key from the domain’s edit page (/edit/domain/<your-domain>) and set it aside - it’s the load-bearing piece of the DNS configuration that comes next.
In other words: create the domain, touch nothing else, copy the DKIM key, move on.
The network plumbing
This is where “I’ll just run a mail server” quietly becomes a networking project.
Firewall (OPNsense): Inbound is locked to the ports mailcow actually needs - 25, 465, 587, 993, 995, 4190 - with a destination NAT forwarding each to the mail VM. But here’s the critical split that’s easy to get wrong: the ports divide into two camps, and you must not treat them the same.
Port 25 is how the entire internet delivers mail to you, and mail legitimately originates from everywhere - some random sender in a country you’ve never thought about is exactly who you’re building this to receive from. So port 25 has to accept from any source, full stop. The temptation is to slap a GeoIP filter on it like everything else; don’t. If you GeoIP-restrict port 25, you simply will never see mail from outside that scope - it’ll be dropped at the edge and you’ll spend a confused afternoon wondering why half your mail vanishes. The right move on 25 is the opposite: take it from anyone, then lean on IP reputation and blacklists (RBLs, the sending IP’s standing, all the deliverability machinery) to decide what’s junk after it arrives.
The client-access ports - 465/587 for submission, 993/995 for IMAP/POP over TLS, 4190 for Sieve - are the opposite case. That’s where you and your users connect, not the open internet, so it’s entirely reasonable to scope them down hard. I put those behind a GeoIP alias limited to the countries my users actually log in from, so the rest of the planet can’t even knock. Plaintext IMAP/POP are never exposed at all.
One caveat on the GeoIP piece: it may not be possible in your situation. I’m fronting this with OPNsense, which ships GeoIP filtering as a built-in feature, so it’s a few clicks for me. Your firewall may not have it, may charge for the GeoIP database, or may not sit in a position to filter inbound at all - so your mileage will absolutely vary here. If you can’t do GeoIP, the client ports lose a layer but still ride on TLS and authentication; it’s defense in depth, not the only line.
Reverse proxy (Traefik): My Traefik is already up and running, fronting half the lab - standing it up is a whole post of its own, which I may or may not ever get around to writing (place your bets). For now, assume it exists and just look at the bit relevant to mailcow: HTTP/HTTPS for mail, autodiscover, and autoconfig hostnames are matched by host regex and routed to the mailcow backend, with HSTS and a CrowdSec middleware in front. Autodiscover/autoconfig mean my mail clients configure themselves, which feels like magic until you read the routing rules. Here’s the dynamic config:
http:
routers:
mailcow-http:
rule: "HostRegexp(`^mail\\..*`)"
entryPoints:
- http
middlewares:
- https-redirect
- crowdsec@file
service: mailcow-http
mailcow-https:
rule: "HostRegexp(`^mail\\..*`)"
entryPoints:
- https
middlewares:
- hsts-header
- crowdsec@file
service: mailcow-http
tls: {}
mailcow-autodiscover-http:
rule: "(HostRegexp(`^autodiscover\\..*`) && Path(`/autodiscover/autodiscover.xml`)) || (HostRegexp(`^autoconfig\\..*`) && Path(`/mail/config-v1.1.xml`))"
entryPoints:
- http
middlewares:
- https-redirect
- crowdsec@file
service: mailcow-http
mailcow-autodiscover-https:
rule: "(HostRegexp(`^autodiscover\\..*`) && Path(`/autodiscover/autodiscover.xml`)) || (HostRegexp(`^autoconfig\\..*`) && Path(`/mail/config-v1.1.xml`))"
entryPoints:
- https
middlewares:
- hsts-header
- crowdsec@file
service: mailcow-http
tls: {}
services:
mailcow-http:
loadBalancer:
servers:
- url: "https://mail-sr-1.maas.us1.thefathacker.net:443"
Note the wildcard host matching - ^mail\..*, ^autodiscover\..*, ^autoconfig\..* rather than spelling out every domain. This is pure, unrepentant laziness, and I will defend it. Because I match on the subdomain pattern instead of full hostnames, onboarding a brand-new mail domain takes exactly two steps: make sure my SSL config covers it on the Traefik instances, and add the DNS records. No new router, no redeploy, no editing this file. Add domain, point DNS, hazzah - access. The lazy path and the correct path occasionally converge, and this is one of those times.
The sharp-eyed will spot the obvious question: if the router matches any mail.<something> host, isn’t the only thing keeping a stranger out the fact that I haven’t published a public DNS record for their made-up domain? And the honest answer is - ha - no, that’s not protection at all. hosts files and private DNS resolvers exist. Nothing stops someone from pointing mail.totally-not-mine.example at my IP locally and firing a request at the wildcard match. But that buys them precisely nothing useful: Traefik can only serve a TLS certificate for hostnames I actually hold, so they get a certificate mismatch the moment TLS negotiates, and every one of those oddball requests lands in the logs with their fingerprints on it. The wildcard is a convenience for me, never a security boundary - the real boundaries are the certs and the authentication behind them. Play stupid games, win stupid prizes.
DNS: where deliverability lives or dies
Each hosted domain gets the full incantation - MX, SPF, DKIM, and a strict DMARC - plus mail, autodiscover, and autoconfig CNAMEs pointing at the mail host. The DKIM keys are generated by mailcow and pasted into DNS. Get any of these wrong and your mail vanishes into spam folders with no error message, which is the internet’s idea of a practical joke.
A note on the real domains. You’ll notice I haven’t bothered to redact anything; those are my actual domains, hostnames, and records, not
example.complaceholders. That’s deliberate. Security through obscurity is not security; it’s praying that nobody runs a deep scan, while knowing full well that plenty of people, automated crawlers, and three-letter agencies do exactly that, continuously, as a matter of routine. Hiding a hostname buys me nothing real. Anything reachable on the public internet is already enumerated, indexed, and catalogued whether I publish it here or not. The actual security lives in the configuration, the patching, and the firewall, not in pretending my domain name is a secret. And honestly? If someone is invested enough to target me specifically over a homelab mail server, I’d gently ask: why? I am not that interesting, and neither is my data. The defences below are sized for the internet’s automated background radiation, not for a motivated adversary who, for reasons I cannot fathom, has decided I’m worth the effort.
Here’s roughly what a hosted domain looks like (DKIM key truncated, because the real one is an unreadable wall of base64):
mail CNAME mail.thefathacker.tech.
autodiscover CNAME mail.thefathacker.tech.
autoconfig CNAME mail.thefathacker.tech.
@ MX 0 smtp.thefathacker.tech.
@ TXT "v=spf1 include:netblocks-us.thefathacker.tech include:netblocks-au.thefathacker.tech -all"
dkim._domainkey TXT "v=DKIM1;k=rsa;t=s;s=email;p=MIIBIjANBgkqhkiG9w0BAQEF...AB"
_dmarc TXT "v=DMARC1; p=reject; rua=mailto:[email protected]; ruf=mailto:[email protected]; fo=1; pct=100; ri=43200; adkim=r; aspf=r"
Why SPF ends in -all (and not ~all)
The mechanism at the end of an SPF record tells receivers what to do with mail from a source that isn’t on the authorized list. The common, timid choice is ~all (softfail) - “this is probably forged, but go ahead and deliver it to spam anyway.” That’s a maybe, and maybes get abused.
I use -all (hardfail), which says: “if it didn’t come from one of these sources, it is forged, full stop - reject it.” It’s the difference between a bouncer who frowns at fake IDs and one who actually turns people away. If you genuinely know every system that sends as your domain - and on a self-hosted setup, you do - there’s no reason to leave the door cracked with ~all. Hardfail or it didn’t happen.
And then there’s +all: it authorizes the entire internet to send as your domain. Do I really need to explain why that’s a bad idea?
About those include: mechanisms - include:netblocks-us.thefathacker.tech and include:netblocks-au.thefathacker.tech. Rather than hard-coding raw IP addresses into every domain’s SPF record, I delegate to two of my own subdomains, each of which holds its own little SPF record listing the actual sending IP ranges for that region (US and AU - my mail egresses from both sites). An include simply says “also count whatever that record authorizes as valid for me.”
Why bother? One place to change things. When a sending IP changes - new VPS, new colo range, whatever - I update the netblocks-us or netblocks-au record once, and every domain that includes it inherits the change instantly. No hunting through a dozen zones to update the same IP in a dozen SPF strings and inevitably missing one. It also keeps each domain’s SPF short and readable, which matters more than it sounds: SPF has a hard limit of 10 DNS lookups per evaluation, and a sprawling record full of nested includes will blow past it and fail. Two tidy regional includes keeps me comfortably under budget.
DKIM, and why “alignment” is the whole game
Quick detour, because DMARC makes no sense without it. DKIM (DomainKeys Identified Mail) is a cryptographic signature: your mail server holds a private key, signs each outgoing message’s headers with it, and publishes the matching public key in DNS (that dkim._domainkey TXT record above). That public key, by the way, is exactly the value you copied back when you created the domain - remember “create the domain, touch nothing else, copy the DKIM key”? This is where it gets pasted in. A receiver fetches the public key, checks the signature, and now knows two things - the mail genuinely came from a system holding your key, and nobody tampered with it in transit. SPF, by contrast, just authorizes which IPs may send for your domain. Different proofs, same goal.
Here’s the part that trips people up: for DMARC to pass, it isn’t enough for SPF or DKIM to merely succeed - the domain they validate has to align with the domain in the visible From: header. In the overwhelming majority of cases, that means a legitimate email needs the From: domain to match either its DKIM signing domain or its SPF (envelope/return-path) domain. Pass one aligned check and DMARC is satisfied; fail both and it’s rejected (with p=reject, anyway).
Are there exceptions? Sure - at serious scale, with mailing lists that rewrite headers, ESPs that sign with their own domain, or high-volume senders doing exotic routing, alignment gets genuinely fiddly. But if you’re reading a homelab post about standing up your own mail server and you somehow fall into the “DKIM/SPF alignment doesn’t apply to me” bucket… why? What are you doing? Go back to the people whose full-time job is deliverability. For the rest of us: align with DKIM or SPF, or your mail doesn’t pass. Simple.
Why DMARC is p=reject
SPF and DKIM each prove a part of an email’s legitimacy; DMARC ties them to the visible From: address and tells receivers what to do when that alignment fails. The policy values escalate: p=none (monitor only, do nothing), p=quarantine (send failures to spam), and p=reject (refuse them outright at the door).
p=none is where you start - collect a week or two of rua aggregate reports, confirm your own legitimate mail is passing, then tighten. But the destination is p=reject. Anything less leaves room for someone to spoof your domain and have it land in an inbox. The rest of the record is just tuning: rua/ruf point reports at my (sarcastically named) forensic mailbox, fo=1 asks for failure samples whenever either SPF or DKIM fails to align, pct=100 applies the policy to all mail, and adkim=r/aspf=r keep alignment in relaxed mode so subdomains and mail. hosts don’t trip over strict matching. Strict (s) alignment is purist and tidy, but relaxed is what survives contact with real-world mail flows.
The throughline: -all and p=reject are the same instinct - if it isn’t provably mine, it isn’t mine, so reject it. Half-measures here just teach the internet that spoofing your domain is worth a shot.
A confession: the PTR record (or, the gap I’m owning up to)
In the interest of honesty, here’s a gap in my current setup. Everything above lives in the forward DNS - the records you publish in your own zone. But there’s a record that points the other direction: the PTR, or reverse DNS. It maps your sending IP back to a hostname, and ideally that hostname matches your mail server’s HELO (mail.thefathacker.tech) and forward-resolves right back to the same IP - a round trip the spec people call FCrDNS (forward-confirmed reverse DNS).
Some receivers care about this a great deal. A missing or mismatched PTR is a classic, fast way to get hardfailed or spam-foldered, regardless of how immaculate your SPF, DKIM, and DMARC are. In practice, though, I haven’t run into many that are strict about it when the other controls all check out - a clean IP reputation plus aligned SPF/DKIM seems to satisfy the overwhelming majority of real-world mail flows I’ve dealt with. Your mileage will vary, and the pickiest of the big providers can absolutely get upset about it.
The catch - and the reason it slipped the DNS section entirely - is that you usually don’t set the PTR yourself. It’s controlled by whoever owns the IP block: your colo or VPS provider sets it via their portal or a support ticket, not in your own zone. In my case, the colo provider I use for my homelab doesn’t currently have a self-service process to make this change - though that may well change as they keep building out their tooling. And if you’re on a residential connection (which, per the gotchas at the top, you probably shouldn’t be running mail on anyway), you may not be able to set a custom PTR at all - the ISP owns it and that’s that. So treat this as a known gap on my end and a “check whether you even can” item on yours.
The domains you don’t send from
Here’s the one most people miss entirely. Everything above is for domains you actively send mail from. But what about that domain you registered for a project that never happened, or the one you only use for a website, or the dozen you’re squatting on for later? Every domain you own that doesn’t send email is a free spoofing target - and because you’re not watching it, you’ll never even know it’s being abused to phish people in your name.
So make this your default for any non-sending domain, set the day you register it: a locked-down SPF and a reject-everything DMARC.
@ TXT "v=spf1 -all"
_dmarc TXT "v=DMARC1; p=reject;"
v=spf1 -all says “no host on earth is authorized to send as this domain - reject all of it,” and p=reject tells receivers to bin anything claiming to be from it. Together they slam the door on anyone trying to use your domain without your knowledge. It costs you two DNS records and zero ongoing effort, and it’s the single highest-leverage thing you can do for a domain you’re not actively using. Belt-and-braces: if the domain truly never receives mail either, a null MX (@ MX 0 .) makes that explicit too.
The cold spare
The whole reason for node two. mailcow ships a create_cold_standby.sh script that rsyncs the entire stack to a standby host over SSH. Set up a key, point the script at the remote, and a nightly cron job replicates everything at 01:00. If the primary dies, the spare is a docker compose up away from taking over. In theory. I have not yet tested failover, which means it does not work yet - I just don’t know it.
Setting it up
First, the SSH key. The script runs as root on the primary and connects as root on the standby, so the key lives in root’s keystore. Generate it on the primary:
root@mail-sr-1:/opt/mailcow-dockerized# ssh-keygen
root@mail-sr-1:/opt/mailcow-dockerized# cat /root/.ssh/id_ed25519.pub
Then copy that public key into the standby’s /root/.ssh/authorized_keys so the primary can log in unattended.
Yes - this is root-to-root, passwordless SSH between two hosts. Under any normal circumstance I’d call that a fireable offense: it means a compromise of the primary hands an attacker root on the spare for free. But the script needs root on both ends to replicate the full mailcow stack (volumes, configs, permissions and all), so this is the trade-off mailcow asks for. Mitigate it like you mean it - keep that key dedicated to this one job, lock the standby’s SSH down so it only accepts that key from the primary’s address, and never reuse it anywhere else. It’s a deliberate, contained exception, not a habit.
Next, point the script at the remote. Edit the parameters at the top of /opt/mailcow-dockerized/create_cold_standby.sh:
export REMOTE_SSH_KEY=/root/.ssh/id_ed25519
export REMOTE_SSH_PORT=22
export REMOTE_SSH_HOST=mail-sr-2.maas.us1.thefathacker.net
Now run it once, by hand, before you ever trust it to a cron job:
bash /opt/mailcow-dockerized/create_cold_standby.sh
Watch it complete cleanly end-to-end. A manual run is also how you confirm SSH auth actually works and the standby has the disk for it - far better to find that out at a terminal than buried in a 01:00 cron log nobody reads.
And that “in theory” is doing a lot of work. Veeam - one of the premier names in backup and replication - has hammered this point for years: if you don’t have a process to test your backups, do your backups actually exist? Their sysadmin story on testing your backups is worth a read and makes the case better than I can. An untested backup isn’t a backup, it’s a hope with a file size. The same logic applies to a cold spare. Until I’ve actually killed the primary and confirmed the standby picks up mail, all I really have is 200GB of optimism replicated nightly. Testing failover is squarely on the TODO list - which, if we’re honest, is exactly where untested backups go to feel reassured about themselves.
One genuinely handy bonus: this same script isn’t only for disaster standby. It’s a perfectly good way to migrate to a new host - for instance during a major OS upgrade where you’d rather build a fresh box than upgrade in place. Stand up the new host, point REMOTE_SSH_HOST at it, run the copy, and you’ve moved your entire mailcow install with one command. Same mechanism, different intent.
Backups
Two layers, both on cron:
# Cold-standby replication - daily 01:00, output logged (last run only)
0 1 * * * root bash /opt/mailcow-dockerized/create_cold_standby.sh > /var/log/mailcow-coldstandby-sync.log 2>&1
# Local full backup to CIFS share - daily 04:05, prune >30 days
5 4 * * * root cd /opt/mailcow-dockerized/; MAILCOW_BACKUP_LOCATION=/opt/backup /opt/mailcow-dockerized/helper-scripts/backup_and_restore.sh backup all --delete-days 30
A full backup can also be forced on demand:
MAILCOW_BACKUP_LOCATION=/opt/backup THREADS=2 \
/opt/mailcow-dockerized/helper-scripts/backup_and_restore.sh backup all
A crucial detail hiding in that MAILCOW_BACKUP_LOCATION=/opt/backup: /opt/backup is a CIFS mount on a separate host (a TrueNAS box), not a folder on the mail server itself. This matters more than it looks. A “backup” sitting on the same machine as the thing it’s backing up is not a backup - when that machine dies, burns, or gets encrypted, your backup goes with it. You’ve just made a redundant copy of your data on a single point of failure.
This is the 3-2-1 rule, the bedrock of not losing your data: 3 copies, on 2 different media, with 1 off-site. My live data, the cold-standby replica, and the CIFS-mounted backup on the NAS get me the 3-and-2 comfortably. The off-site copy, though? That’s the honest gap - everything currently lives in the same place, which means a single-site disaster (fire, flood, theft, a sufficiently bad day) takes out every copy at once. Genuine off-site backup is on the TODO list, and I’m not going to pretend otherwise. And if you want full marks, Veeam extended the rule to 3-2-1-1-0: the extra 1 being one copy that’s offline/air-gapped or immutable (ransomware can’t encrypt what it can’t reach), and the 0 meaning zero errors on backup verification - which loops right back to actually testing your restores. I’m not all the way to 3-2-1-1-0 yet, but I at least know which direction to crawl.
The NAS does buy me some defence on the “immutable” front. Because the backup target is TrueNAS, I get ZFS snapshots and ZFS replication essentially for free, which blunts a lot of the ransomware risk: even if something scribbles over the live backup share, a read-only snapshot from before the incident is still sitting there to roll back to. That’s genuinely valuable - but it is not a get-out-of-jail-free card. The share is mounted on the mail server, so if that host is fully compromised, an attacker is operating with access to the mounted path and there’s a real chance the share - and potentially the snapshots, depending on how locked-down the NAS is - could be impacted too. A live mount is an attack surface, full stop.
This is exactly why mailcow itself recommends a stronger pattern: their backup strategy using rsync plus the backup script runs the normal backup and then rsyncs the result to a separate target after completion. The key advantage is that this remote target doesn’t have to stay online - it can be powered off, or only powered on for the duration of the copy, genuinely isolating the backup from the production host rather than leaving it perpetually mounted and reachable. That’s the difference between “off-site” and “actually air-gapped,” and it’s the obvious next upgrade to my setup.
For once in my homelab career, the backups exist before the disaster. Mark the date.
Where things stand
The stack is up, certs renew over DNS-01, DKIM is signing, DMARC reports are flowing, and mail actually sends and receives. It’s monitored, it’s backed up, and there’s a cold spare in the wings. By homelab standards this is alarmingly responsible.
Things not yet done
Because the trademark TODO list is mandatory:
- MTA-STS - enforce inbound TLS from external senders
- ClamAV - find better signature sources than the defaults
- Layered firewall protections - CrowdSec + Suricata on the edge
- OIDC via Authentik - single sign-on for the admin UI
- parsedmarc - actually visualize the DMARC reports instead of squinting at XML
- Outbound firewall rules - currently more “trusting” than I’d like
- Actually test the cold-spare failover (the funniest item on this list)
- Check for updates on occasion - keep mailcow patched, because between AI-assisted attackers and a steady drip of CVEs, an unpatched mail server is just a future incident with a countdown timer
- Off-site backup - satisfy the “1” in 3-2-1; right now every copy lives in the same place, so a single-site disaster takes the lot
- And any number of other number of one-day jobs
A quick verdict on mailcow-dockerized
A short review of the tool I picked. So far, I’m genuinely happy with mailcow-dockerized. It does an excellent job of packing a full, properly-featured email platform - Postfix, Dovecot, Rspamd, SOGo, DKIM, web admin, the lot - into one coherent stack that actually hangs together, instead of leaving you to duct-tape a dozen daemons into something resembling a mail server. For “I want real email, batteries included,” it delivers.
The one thing I personally don’t love is the requirement to run as root. I understand why - it’s wrangling Docker, low ports, certificates, and a fistful of containers - but root is root, and that carries real security weight. More to the point, it’s a big part of why the mailcow docs are emphatic about running this on a dedicated machine, VM or physical. This is not a stack you casually co-locate next to your other services and hope for the best. It wants the whole box to itself, and given the root footprint, you should give it exactly that - which, conveniently, is precisely what those two dedicated VMs back at the top were for.
The other thing on my wishlist (and a quick caveat: this is all true at the time of writing, and mailcow moves quickly, so some of it may well be solved by the time you read this) is better certificate management. Right now certificates are driven entirely from config files. That’s fine until you want something less uniform, like having each domain carry its own separate certificate validated over DNS. In the current setup that is not a simple thing to arrange. If I were using HTTP validation it would likely be a non-issue, but I’m deliberately not using HTTP, I’m doing DNS-01. That means the single ACME setup baked into mailcow is reaching into multiple DNS zones using my one Cloudflare API key to validate everything together. It works, but it’s coarse. This would be far better managed, and far more self-service, if certificates could be configured per domain, with their own validation settings, and ideally driven from the web console rather than hand-edited config. For a single operator it’s a minor annoyance; the moment you’re hosting domains for other people, per-domain self-service certs would be a genuinely big quality-of-life win.
None of this is a dealbreaker. It’s just the trade-offs you’re accepting, and you should accept them with your eyes open rather than discovering them later.
A serious word before you go
I’ll drop the bit for a moment, because this part matters.
Email is not a toy. It’s an authentication backstop for nearly every account you own, it carries things people genuinely care about, and it lives on the open internet whether you’re paying attention or not. Self-hosting it means you are now personally responsible for deliverability, security, privacy, and uptime - there is no support line, no one else watching the logs, and no one to blame when it breaks. Go in with that fully understood, and treat every one of those concerns with the weight it deserves.
And to be absolutely clear: I did this my way, for my own reasons. This is not the best way, and it is certainly not the only way. Anyone who tells you there is one true, correct way to do something like this is either trying to sell you something or doesn’t actually know what they’re talking about - sometimes both. Technology offers a dozen routes to any given destination. Plenty of them are dangerous, and most of those will only ever hurt your own data and your own sanity - but “only” is doing a lot of work in that sentence.
So read widely, understand why each piece is there before you deploy it, assume you’ll get something wrong, and build like you’ll have to recover from your own mistakes - because you will. Take what’s useful here, ignore what isn’t, and make your own informed decisions. That’s the entire point of running your own infrastructure.
Right. Bit’s back on.
Next post: maybe hardening the edge and finding out whether that cold spare was a good idea or just an expensive second thing to back up.
Stay tuned. And keep a fire extinguisher near the rack. Or don’t, i’m not your mum.
Finally, a disclaimer: this post was written with the help of Claude Opus (Maybe one-day I will have my AI server running, add to todo). That said, I’ve read and approved every word of it, and most of the questionable humour is regrettably my own. Without Claude, this would have been a barely-readable wall of text - and no, I will not be showing you my original notes. Not even I fully understand it.