Mail Server
Mailcow Dockerized Setup Guide (Home Server + VPS Proxy + Tailscale)
This guide installs Mailcow on your home server (behind CGNAT), with your VPS handling public-facing TLS/proxying via Caddy (web UI/webmail) and HAProxy (raw mail ports), connected over Tailscale. Sourced from the current official Mailcow docs (docs.mailcow.email) as of mid-2026.
0. Architecture overview
Internet
│
▼
VPS (public IP)
├── Caddy → HTTPS termination for mail.seabee.me (webmail/admin UI)
│ reverse_proxy → home server Tailscale IP:8005 (plain HTTP)
└── HAProxy → raw TCP forward for 25 / 465 / 587 / 993 / 995 / 4190
→ home server Tailscale IP (same ports)
│
▼ (Tailscale tunnel)
Home server (behind CGNAT)
└── Mailcow (Docker): Postfix, Dovecot, SOGo, Rspamd, ClamAV, etc.
Mailcow itself runs entirely on your home server. The VPS never stores mail — it just forwards TLS/TCP traffic through the Tailscale tunnel. This means Mailcow's own built-in Let's Encrypt client should be disabled, since Caddy on the VPS is what the outside world actually talks to on 443.
1. Prerequisites
Hardware (home server)
- 2+ CPU cores (4+ for production/multiple domains)
- 6 GB RAM minimum + 1 GB swap (8 GB recommended if running ClamAV/full-text search)
- 20 GB disk minimum, more for mailbox storage
If your home server is resource-constrained, you can disable ClamAV and full-text search in mailcow.conf (SKIP_CLAMD=y, SKIP_FTS=y) to run comfortably on ~2 GB RAM.
OS support
Mailcow supports KVM/ESXi/Hyper-V VMs and bare metal. It explicitly does not support Synology/QNAP NAS devices, OpenVZ, or LXC containers — only real Docker hosts.
Required packages
sudo apt update
sudo apt install -y git openssl curl gawk coreutils grep jq
(jq is a fairly recent addition to Mailcow's requirements — make sure it's installed.)
Docker (latest engine, not distro package)
curl -sSL https://get.docker.com/ | CHANNEL=stable sh
sudo systemctl enable --now docker
Docker Compose plugin (v2.0+)
sudo apt install docker-compose-plugin
Confirm:
docker compose version # must be >= 2.0
Debian 13 (trixie) note
If you upgraded from Debian 12→13, a package called exim may get pulled in and bind port 25 on the host, blocking Mailcow's Postfix container from using it. Remove it first:
sudo apt remove --purge exim4 exim4-base exim4-config -y
2. DNS records to configure
Set these at your domain registrar (Namecheap → Advanced DNS), pointing at your VPS's public IP (since that's what actually receives inbound connections):
# Name Type Value
mail IN A 203.57.114.42
autodiscover IN CNAME mail.seabee.me.
autoconfig IN CNAME mail.seabee.me.
@ IN MX 10 mail.seabee.me.
In Namecheap specifically
-
The A record and CNAME records go under Advanced DNS → Host Records (Add New Record).
-
The MX record goes under Advanced DNS → Mail Settings → Custom MX, filled in as:
Field Value Type MX Record Host @Value mail.seabee.me.Priority 10TTL Automatic -
Important: the MX record only resolves correctly if the
mailA record above already exists — MX points to a hostname, not an IP directly. -
Check Host Records for a stray CNAME on
@(bare domain) — Namecheap gives CNAME priority over MX on the same host, which silently breaks mail delivery if one exists.
SPF, DKIM, DMARC
@ IN TXT "v=spf1 mx a -all"
_dmarc IN TXT "v=DMARC1; p=reject; rua=mailto:postmaster@seabee.me"
Both go under Host Records as TXT records. DKIM is generated inside the Mailcow admin UI after install (Configuration → ARC/DKIM keys) — you'll copy that TXT record in afterward, also as a Host Record.
Reverse DNS (PTR)
Critical for deliverability: your VPS provider, not your DNS zone, controls this — PTR belongs to whoever owns the IP block. The PTR record needs to resolve 203.57.114.42 back to mail.seabee.me.
On Binary Lane: mPanel → Network/IP settings → the "Reverse DNS" field next to your public IPv4 address. Edit it and set it to mail.seabee.me. Binary Lane uses a 12-hour TTL for PTR records, so allow up to half a day for it to propagate. Set the mail A record first — forward and reverse should match, and some providers expect the forward record to already resolve.
Verify once propagated:
dig +short mail.seabee.me # → your VPS IP
dig +short -x 203.57.114.42 # → mail.seabee.me.
Changing the PTR only affects reverse lookups on that IP — any other hostnames already pointing at the same IP (e.g. a vps.seabee.me you use for SSH) keep working exactly as before, since PTR and A records are independent.
Optional but recommended: autoconfig SRV records
Full zone-file form:
_autodiscover._tcp IN SRV 0 1 443 mail.seabee.me.
_imaps._tcp IN SRV 0 1 993 mail.seabee.me.
_pop3s._tcp IN SRV 0 1 995 mail.seabee.me.
_submission._tcp IN SRV 0 1 587 mail.seabee.me.
_submissions._tcp IN SRV 0 1 465 mail.seabee.me.
_sieve._tcp IN SRV 0 1 4190 mail.seabee.me.
In Namecheap, SRV records use separate fields rather than one string. Add each one under Host Records → Add New Record → SRV Record:
| Service | Protocol | Priority | Weight | Port | Target |
|---|---|---|---|---|---|
_autodiscover |
_tcp |
0 | 1 | 443 | mail.seabee.me |
_imaps |
_tcp |
0 | 1 | 993 | mail.seabee.me |
_pop3s |
_tcp |
0 | 1 | 995 | mail.seabee.me |
_submission |
_tcp |
0 | 1 | 587 | mail.seabee.me |
_submissions |
_tcp |
0 | 1 | 465 | mail.seabee.me |
_sieve |
_tcp |
0 | 1 | 4190 | mail.seabee.me |
These aren't required for mail to function — the autodiscover/autoconfig CNAMEs above already cover most clients — but they add broader compatibility (notably Outlook via _autodiscover). Fine to add later once core mail delivery is confirmed working.
3. Install Mailcow on the home server
umask 0022
mkdir -p /home/conor/Docker
cd /home/conor/Docker
git clone https://github.com/mailcow/mailcow-dockerized mailcow
cd mailcow
./generate_config.sh
Note: Mailcow's own docs default to /opt/mailcow-dockerized, but any path works — Mailcow doesn't hardcode /opt anywhere; it just uses whatever directory you run generate_config.sh and docker compose from. The only requirement is that the user running these commands has read/write access to the directory and to the Docker socket (i.e. is in the docker group, or you run the commands with sudo).
You'll be prompted for:
- Mailcow hostname → enter
mail.seabee.me(must match your DNSA/MXrecords) - Timezone
This creates mailcow.conf.
4. Edit mailcow.conf — key settings for your proxy setup
nano mailcow.conf
Change/confirm these values:
MAILCOW_HOSTNAME=mail.seabee.me
# Disable Mailcow's built-in Let's Encrypt — Caddy on the VPS handles TLS instead
SKIP_LETS_ENCRYPT=y
# Skip the public-IP-matches-DNS check, since this host is behind CGNAT
SKIP_IP_CHECK=y
# Disable IPv6 — nothing in the VPS→Tailscale→home path uses it, and half-enabling
# it (detected but not configured in Docker's daemon.json) just invites confusion
ENABLE_IPV6=false
# nginx (webmail/admin UI) — scoped to this host's Tailscale IP only.
# This is the ONLY service where mailcow.conf controls the bind address directly;
# Postfix/Dovecot ports have no _BIND variable and are handled in the Docker
# Compose override file instead (see Section 5).
HTTP_BIND=100.64.0.3
HTTP_PORT=8005
HTTPS_BIND=100.64.0.3
HTTPS_PORT=8443
# Adjust if these clash with anything else on your Docker host
IPV4_NETWORK=172.22.1
Replace 100.64.0.3 with your home server's actual Tailscale IP (tailscale ip -4).
Why SKIP_LETS_ENCRYPT=y: Mailcow's ACME client tries to prove domain ownership by responding on port 443 with the host's own certificate. Since the VPS (via Caddy) is what the public internet actually reaches, Mailcow's own ACME client would fail here anyway — Caddy already handles issuance/renewal for mail.seabee.me.
Why HTTP_PORT=8005 rather than the Mailcow default of 8080: purely to avoid clashing with anything else already using 8080 on this host — pick whatever's free on your system, just make sure the same number is used consistently in the Caddy reverse_proxy line in Section 8.
5. Bind Postfix/Dovecot ports to Tailscale only
By default, Mailcow's docker-compose.yml binds mail ports to all interfaces (0.0.0.0). Since your home server sits behind CGNAT, nothing on the raw internet can reach these ports regardless — but leaving them on 0.0.0.0 still means every device on your home LAN and every peer on your tailnet can connect to them directly, bypassing HAProxy entirely. Scoping to the Tailscale IP limits that down to just the VPS.
Only Postfix and Dovecot need this override. nginx (webmail/admin UI) is already scoped via HTTP_BIND/HTTPS_BIND in mailcow.conf (Section 4) — that's the only service where the base docker-compose.yml exposes a bind-address variable. Postfix and Dovecot's port lines have no such variable, so a Compose override is the only way to restrict them.
Get your home server's Tailscale IP:
tailscale ip -4
Create docker-compose.override.yml in the same folder as docker-compose.yml (never edit docker-compose.yml directly — ./update.sh overwrites it on every update, but the override file is preserved):
services:
postfix-mailcow:
ports:
- "100.64.0.3:25:25"
- "100.64.0.3:465:465"
- "100.64.0.3:587:587"
dovecot-mailcow:
ports:
- "100.64.0.3:993:993"
- "100.64.0.3:995:995"
- "100.64.0.3:4190:4190"
Replace 100.64.0.3 with your home server's actual Tailscale IP. If you also want LAN clients (phones/laptops on your home WiFi) to connect directly without going through the VPS, add a second binding per port using your LAN IP instead of, or alongside, the Tailscale one.
Apply it:
docker compose up -d
docker compose ps
Compose recreates only the containers whose config changed.
Verify the bindings landed on the right interface:
ss -tlpn | grep -E '25|465|587|993|995|4190|8005|8443'
You should see 100.64.0.3 (not 0.0.0.0) next to each port.
6. Home server firewall
Restrict inbound access on these ports to just your VPS's Tailscale IP, since that's the only legitimate source of forwarded traffic:
sudo ufw allow from <VPS_TAILSCALE_IP> to any port 25,465,587,993,995,4190,8005,8443 proto tcp
(Adjust for iptables/nftables if that's what you're running instead.)
7. Start Mailcow
docker compose pull
docker compose up -d
docker compose ps
You should see 15+ containers reporting Up.
8. VPS side — wire up Caddy and HAProxy
Caddy (web UI / webmail / autodiscover — HTTPS termination)
Add a new site block to your existing Caddyfile:
mail.seabee.me {
reverse_proxy 100.64.0.3:8005
}
autodiscover.seabee.me, autoconfig.seabee.me {
reverse_proxy 100.64.0.3:8005
}
Caddy handles the ACME cert issuance/renewal automatically here, same as your other sites — no extra config needed for that part.
HAProxy (raw mail ports — TCP passthrough)
This is the same pattern from your earlier HAProxy config — just confirm the backend IPs point at your home server's Tailscale IP and the ports match Mailcow's:
frontend smtp_in
mode tcp
option tcplog
bind *:25
default_backend smtp_home
backend smtp_home
mode tcp
server home 100.64.0.3:25 check
frontend smtp_submission_in
mode tcp
option tcplog
bind *:587
default_backend smtp_submission_home
backend smtp_submission_home
mode tcp
server home 100.64.0.3:587 check
frontend smtps_in
mode tcp
option tcplog
bind *:465
default_backend smtps_home
backend smtps_home
mode tcp
server home 100.64.0.3:465 check
frontend imaps_in
mode tcp
option tcplog
bind *:993
default_backend imaps_home
backend imaps_home
mode tcp
server home 100.64.0.3:993 check
frontend pop3s_in
mode tcp
option tcplog
bind *:995
default_backend pop3s_home
backend pop3s_home
mode tcp
server home 100.64.0.3:995 check
frontend sieve_in
mode tcp
option tcplog
bind *:4190
default_backend sieve_home
backend sieve_home
mode tcp
server home 100.64.0.3:4190 check
Reload HAProxy:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
On send-proxy: as covered before, only add this once Postfix/Dovecot on the home server are explicitly configured with postscreen_upstream_proxy_protocol = haproxy / haproxy_trusted_networks. Get everything working without it first.
VPS firewall
sudo ufw allow 25,465,587,993,995,4190/tcp
(80/443 should already be open for Caddy.)
9. First login
https://mail.seabee.me/admin
Default credentials: admin / moohoo — change this immediately under Configuration → Access → Admin details.
10. Post-install checklist
- DKIM: generate a key in the admin UI (Configuration → ARC/DKIM keys), then add the TXT record it gives you to your DNS
- Change the default admin password (step 9)
- Fail2Ban: built into Mailcow, verify it's enabled in the admin panel
- Watchdog notifications: configure so you get alerted if a component goes down
- Updates: run
./update.shfrom/home/conor/Docker/mailcowmonthly at minimum; more often for security patches
11. Testing
# From an external machine, confirm each port reaches Mailcow
openssl s_client -starttls smtp -connect mail.seabee.me:587
openssl s_client -connect mail.seabee.me:993
DNS/deliverability checks:
- MX Toolbox — MX, SPF, blacklist check
- Mail-tester.com — send a test email, get a deliverability score covering SPF/DKIM/DMARC
- Send to
check-auth@verifier.port25.comfor a raw authentication report
Quick reference: port map
| Service | Port | VPS component | Forwards to |
|---|---|---|---|
| Webmail/Admin UI | 443 | Caddy (HTTPS) | home:8005 (HTTP) |
| SMTP | 25 | HAProxy (TCP) | home:25 |
| Submission | 587 | HAProxy (TCP) | home:587 |
| SMTPS | 465 | HAProxy (TCP) | home:465 |
| IMAPS | 993 | HAProxy (TCP) | home:993 |
| POP3S | 995 | HAProxy (TCP) | home:995 |
| ManageSieve | 4190 | HAProxy (TCP) | home:4190 |