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:8443 (HTTPS, verify skipped)
├── HAProxy → raw TCP forward for 25 / 465 / 587 / 993 / 995 / 4190
│ → home server Tailscale IP (same ports), with PROXY protocol
│ so the real client IP survives (Section 8)
└── Postfix relay (send-only, Tailscale-facing)
← mailcow relays ALL outbound here; egresses from the VPS
public IP so it matches your PTR + SPF (Section 9)
│
▼ (Tailscale tunnel)
Home server (behind CGNAT)
└── Mailcow (Docker): Postfix, Dovecot, SOGo, Rspamd, ClamAV, etc.
Two things this topology forces that a normal single-box mailcow doesn't:
- Outbound must be relayed through the VPS (Section 9). The home server is behind CGNAT, so mail sent directly would leave from the ISP's shared IP — wrong PTR, no SPF match, port 25 likely blocked. Relaying through the VPS makes the sending IP match
mail.seabee.me. - Inbound must carry the client's real IP via PROXY protocol (Section 8), otherwise every connection looks like it came from the VPS and mailcow's fail2ban will ban your own relay.
Mailcow itself runs entirely on your home server. The VPS never stores mail — it just forwards TLS/TCP traffic through the Tailscale tunnel.
On certificates: Caddy on the VPS obtains a cert for the web UI (it has public 80/443, so HTTP-01 works there). But the raw mail ports (465/587/993/995) are TCP-passed-through, so their TLS terminates inside Dovecot/Postfix on the home server — those services need their own valid cert or clients hit "untrusted certificate". The home server is behind CGNAT and can't answer HTTP-01, so it can't run the normal ACME flow. This guide solves it by syncing the cert Caddy already holds down to the home server (Section 4a, "Option B") — cbcore pulls it over Tailscale, drops it into mailcow, and restarts the mail services. mailcow's own ACME client stays off (SKIP_LETS_ENCRYPT=y) so it doesn't fight the synced cert. A DNS-01 alternative is documented too, for anyone whose DNS provider has a usable API.
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=none; 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.
On the SPF mx a -all: this only holds true because outbound mail is relayed through the VPS (Section 9), so mail leaves from the VPS IP, which is exactly what mail.seabee.me's A record (and therefore mx) resolves to. If you ever let the home server send directly, it egresses from the CGNAT IP and this SPF fails hard — see Section 9.
Stage DMARC — do not launch at p=reject. Start at p=none as above. It changes nothing about delivery but makes receivers send you aggregate reports at the rua address. Watch those for a week or two and confirm your legitimate mail is passing DKIM and SPF with alignment. Only then tighten to p=quarantine, and later p=reject. Launching straight at reject means any alignment mistake silently bins your own mail at the recipient with no warning.
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
# Turn mailcow's own ACME client OFF. We supply the cert externally by syncing
# Caddy's cert into mailcow (Section 4a, Option B), so mailcow must NOT try to
# obtain/renew its own — otherwise it overwrites the synced files.
# (If you instead choose the DNS-01 alternative in 4a, set these the other way:
# SKIP_LETS_ENCRYPT=n / ACME_DNS_CHALLENGE=y.)
SKIP_LETS_ENCRYPT=y
ACME_DNS_CHALLENGE=n
# 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
# Web UI / webmail (nginx). HTTP is bound with a SEPARATE bind + port pair.
# This is a mailcow quirk: for technical reasons the http service is bound
# differently from every other service (which use the single IP:PORT form below).
HTTP_BIND=100.64.0.3
HTTP_PORT=8005
HTTPS_BIND=100.64.0.3
HTTPS_PORT=8443
# Mail services (Postfix/Dovecot). Each is bound with a single IP:PORT variable.
# This is the native mailcow mechanism for scoping mail ports to one interface —
# do NOT use a docker-compose.override.yml for this (see Section 5 for why).
SMTP_PORT=100.64.0.3:25
SMTPS_PORT=100.64.0.3:465
SUBMISSION_PORT=100.64.0.3:587
IMAP_PORT=100.64.0.3:143
IMAPS_PORT=100.64.0.3:993
POP_PORT=100.64.0.3:110
POPS_PORT=100.64.0.3:995
SIEVE_PORT=100.64.0.3:4190
# Leave the local-only admin/DB ports on 127.0.0.1 — do not scope these to Tailscale
# DOVEADM_PORT=127.0.0.1:19991
# SQL_PORT=127.0.0.1:13306
# REDIS_PORT=127.0.0.1:7654
# 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 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. Note Caddy proxies to the HTTPS port (HTTPS_PORT=8443), not this one (see Section 8 for why); 8005 only needs to be free and consistent, it isn't the reverse-proxy target.
4a. Certificates for the mail ports
Caddy on the VPS handles TLS for the web UI. But HAProxy passes the mail ports (465/587/993/995) through as raw TCP, so their TLS is terminated by Dovecot/Postfix on the home server — and with a self-signed cert, every mail client shows a warning and remote MTAs distrust your STARTTLS. mailcow's cert is used for mail transport, not just HTTPS, so the home server genuinely needs a trusted one.
The home server can't answer an HTTP-01 challenge (no public 80/443 — the CGNAT problem), so it can't run the normal ACME flow itself. Two ways around that:
- Option B — sync Caddy's cert into mailcow (this guide's default). Caddy on the VPS already holds a valid Let's Encrypt cert for
mail.seabee.me. cbcore pulls that cert over Tailscale, drops it into mailcow, and restarts the mail services. No DNS-provider API needed, nothing changes at your registrar. RequiresSKIP_LETS_ENCRYPT=y(Section 4). - Option A — DNS-01 (documented at the end of this section): mailcow gets its own cert by writing a DNS TXT record. Cleaner and fully self-contained, but only if your DNS provider has a usable API — Namecheap's is gated and IPv4-whitelist-only, which doesn't work from a CGNAT box, so Option B is the default here.
Option B — pull Caddy's cert onto cbcore
The design is pull, not push: cbcore reaches out to the VPS to fetch one cert, rather than the internet-facing VPS holding a key that can write files and restart services on your home server. The VPS key is locked to a forced command that can only emit that one cert — a stolen key can't get a shell.
Step 1 — on the VPS, create the emitter script (streams just the cert + key as a tar on stdout). The 'EOF' is single-quoted so the shell writes the variables literally instead of expanding them now:
sudo tee /usr/local/bin/emit-mail-cert >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
DOMAIN="mail.seabee.me"
CADDY_DATA="/var/lib/caddy/.local/share/caddy/certificates"
crt="$(find "$CADDY_DATA" -type f -name "${DOMAIN}.crt" | head -n1)"
key="$(find "$CADDY_DATA" -type f -name "${DOMAIN}.key" | head -n1)"
[[ -r "$crt" && -r "$key" ]] || { echo "cert not found" >&2; exit 1; }
tar -C "$(dirname "$crt")" -cf - "$(basename "$crt")" "$(basename "$key")"
EOF
sudo chmod 0755 /usr/local/bin/emit-mail-cert
Step 2 — on cbcore, create the pull key and print the public half:
ssh-keygen -t ed25519 -f /home/conor/.ssh/cert_pull -N ''
cat /home/conor/.ssh/cert_pull.pub
Step 3 — on the VPS, authorise that key but lock it to the emitter. Caddy's key is 0600 and root-owned, so the emitter must run as root; put the line in root's authorized_keys. The command=/no-* restrictions mean this key can only emit the cert:
# on the VPS — paste the cbcore public key where shown
sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh
echo 'command="/usr/local/bin/emit-mail-cert",no-port-forwarding,no-X11-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA...PASTE_CBCORE_cert_pull.pub...' \
| sudo tee -a /root/.ssh/authorized_keys >/dev/null
sudo chmod 600 /root/.ssh/authorized_keys
Verify the lock from cbcore — this should list exactly two files and give no shell:
ssh -i /home/conor/.ssh/cert_pull conor@100.64.0.2 | tar -tf -
# → mail.seabee.me.crt and mail.seabee.me.key only
Step 4 — on cbcore, install the sync script. It pulls, checks the cert fingerprint against what's already deployed, and only installs + restarts when it actually changed (so it's safe to run daily and is a no-op the ~59 days out of 60 that nothing changed):
sudo tee /usr/local/bin/pull-mailcow-cert.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
# Pulls the Caddy-issued cert for mail.seabee.me FROM the VPS, installs it into
# mailcow, and restarts mail services ONLY when the cert changed. Idempotent.
set -euo pipefail
VPS_USER="conor"
VPS_HOST="100.64.0.2" # VPS, Tailscale IP
SSH_KEY="/home/conor/.ssh/cert_pull"
MAILCOW="/home/conor/Docker/mailcow"
SSL="${MAILCOW}/data/assets/ssl"
DOMAIN="mail.seabee.me"
SSH_OPTS=(-i "$SSH_KEY" -o BatchMode=yes -o ConnectTimeout=15 \
-o StrictHostKeyChecking=accept-new)
log() { printf '%s %s\n' "$(date -Is)" "$*"; }
fail() { log "ERROR: $*"; exit 1; }
tmp="$(mktemp -d)"; trap 'rm -rf "$tmp"' EXIT
ssh "${SSH_OPTS[@]}" "${VPS_USER}@${VPS_HOST}" | tar -C "$tmp" -xf - \
|| fail "pull from VPS failed (ssh/forced-command/tar)"
crt="$(find "$tmp" -type f -name "${DOMAIN}.crt" | head -n1 || true)"
key="$(find "$tmp" -type f -name "${DOMAIN}.key" | head -n1 || true)"
[[ -r "${crt:-}" && -r "${key:-}" ]] || fail "expected cert/key not in pulled tar"
openssl x509 -in "$crt" -noout -checkend 0 >/dev/null \
|| fail "pulled cert has already expired — refusing to deploy"
new_fp="$(openssl x509 -in "$crt" -noout -fingerprint -sha256 | cut -d= -f2)"
cur_fp="$(openssl x509 -in "${SSL}/cert.pem" -noout -fingerprint -sha256 2>/dev/null \
| cut -d= -f2 || true)"
if [[ -n "$cur_fp" && "$new_fp" == "$cur_fp" ]]; then
log "mailcow already current (${new_fp}); nothing to do."
exit 0
fi
log "new cert (${new_fp}, was ${cur_fp:-none}) — installing and restarting."
install -m 0644 "$crt" "${SSL}/cert.pem"
install -m 0600 "$key" "${SSL}/key.pem"
cd "$MAILCOW"
docker compose restart postfix-mailcow dovecot-mailcow nginx-mailcow \
|| fail "cert installed but container restart failed — check docker"
log "cert deployed and mail services restarted."
EOF
sudo chmod 0755 /usr/local/bin/pull-mailcow-cert.sh
Step 5 — bootstrap once (replaces the snake-oil cert immediately) and confirm cbcore's conor can drive Docker:
docker compose version # must work without sudo (conor in docker group)
/usr/local/bin/pull-mailcow-cert.sh
Step 6 — schedule it daily on cbcore via a systemd timer. Daily rather than monthly on purpose: Caddy renews ~30 days before expiry and that won't align to a monthly cron, so a fixed monthly copy can leave cbcore's cert lapsing before the next run. Daily + the fingerprint guard eliminates that with no extra load:
sudo tee /etc/systemd/system/mailcow-cert-pull.service >/dev/null <<'EOF'
[Unit]
Description=Pull Caddy cert from VPS into mailcow
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=conor
ExecStart=/usr/local/bin/pull-mailcow-cert.sh
EOF
sudo tee /etc/systemd/system/mailcow-cert-pull.timer >/dev/null <<'EOF'
[Unit]
Description=Daily mailcow cert pull
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now mailcow-cert-pull.timer
Step 7 — expiry watchdog (the one thing people skip and regret). The sync breaks silently if Caddy's storage path changes on an update or the key rotates — you'd only find out when the mail cert lapses ~60 days later. This cron on cbcore warns you first:
sudo tee /usr/local/bin/mailcow-cert-expiry-check.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
CERT="/home/conor/Docker/mailcow/data/assets/ssl/cert.pem"
if ! openssl x509 -in "$CERT" -noout -checkend $((10*86400)) >/dev/null 2>&1; then
echo "WARNING: mailcow mail cert ($CERT) expires within 10 days or is missing" >&2
exit 1
fi
EOF
sudo chmod 0755 /usr/local/bin/mailcow-cert-expiry-check.sh
# run daily; a non-zero exit makes cron email you (needs a working MTA/alias for root)
( sudo crontab -l 2>/dev/null; \
echo '30 6 * * * /usr/local/bin/mailcow-cert-expiry-check.sh' ) | sudo crontab -
Verify the whole thing in Section 12 (the openssl s_client … -issuer check should now show Let's Encrypt, not the mailcow snake-oil CA).
Option A — DNS-01 (alternative, only if your DNS API is usable)
If you can use DNS-01, it's cleaner: mailcow gets and renews its own cert, no cross-host sync. Set SKIP_LETS_ENCRYPT=n and ACME_DNS_CHALLENGE=y in Section 4, then pass your DNS provider's API token to the acme-mailcow container. mailcow runs acme.sh, so ACME_DNS_PROVIDER takes an acme.sh dnsapi code. Cloudflare example:
cd /home/conor/Docker/mailcow
sudo tee docker-compose.override.yml >/dev/null <<'EOF'
services:
acme-mailcow:
environment:
- CF_Token=<cloudflare-scoped-api-token>
- CF_Zone_ID=<zone-id-for-seabee.me>
EOF
docker compose up -d acme-mailcow
docker compose logs -f acme-mailcow # watch for a successful issue
Namecheap can't do this from CGNAT. Namecheap's API is granted only above an account threshold and requires whitelisting the calling IPv4 — your acme requests would leave via the ISP's shared, rotating CGNAT address, which you can't reliably whitelist. Moving DNS hosting (not the registrar) to Cloudflare removes both problems (free
dns_cf, token auth, no whitelist); recreate the Section 2 records there and repoint nameservers. If you'd rather not touch DNS, stay on Option B above.
5. Why the mail ports live in mailcow.conf, not an override file
By default, Mailcow 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.
Mailcow binds every non-http service through a single IP:PORT variable in mailcow.conf. You already set these in Section 4 — the format is documented at docs.mailcow.email → IP bindings (SMTP_PORT=1.2.3.4:25 binds SMTP to 1.2.3.4:25):
SMTP_PORT=100.64.0.3:25
SMTPS_PORT=100.64.0.3:465
SUBMISSION_PORT=100.64.0.3:587
IMAP_PORT=100.64.0.3:143
IMAPS_PORT=100.64.0.3:993
POP_PORT=100.64.0.3:110
POPS_PORT=100.64.0.3:995
SIEVE_PORT=100.64.0.3:4190
Do not use a docker-compose.override.yml for this (an earlier draft of this guide did — it's wrong). The base docker-compose.yml already renders each of these ports from the variable above (e.g. ${SMTP_PORT:-25}:25). An override file that also defines a ports: list does not replace the base list — Compose merges the two, and for the multi-value ports option it concatenates/merges by uniqueness key rather than overriding. Depending on your Compose version you either end up with both 0.0.0.0:25 and 100.64.0.3:25 bound (which fails at launch with port is already allocated, since 0.0.0.0:25 already covers the Tailscale IP) or a silent, version-dependent merge. Setting the mailcow.conf variable is the officially documented method and has no merge ambiguity — the base compose file is left untouched, so ./update.sh never fights your config.
A few points worth knowing:
- HTTP is the exception. The web UI is bound with the separate
HTTP_BIND/HTTPS_BIND+HTTP_PORT/HTTPS_PORTpairs (Section 4), not the singleIP:PORTform — a mailcow quirk, for technical reasons http is handled differently from the mail services. - This disables IPv6 for those services. Per the mailcow docs, specifying an explicit IPv4 address skips all IPv6 bindings for that service since Docker 20.x. Fine here —
ENABLE_IPV6=falsealready. - Local-only ports stay put.
DOVEADM_PORT,SQL_PORTandREDIS_PORTremain on their127.0.0.1defaults. Don't scope these to Tailscale. - LAN clients: the
IP:PORTvariable takes a single address. If you also want phones/laptops on your home WiFi to reach the mail ports directly (not via the VPS), that's the one case where you'd add adocker-compose.override.yml— a second, LAN-IP binding per port. Do it deliberately, and remember the merge behaviour above: those override bindings are added to themailcow.confones, so leave theIP:PORTvariable on the Tailscale IP and let the override supply only the extra LAN binding.
Apply the change with a full down/up, since you're changing the rendered port mappings (a bare docker compose up -d won't always re-map ports cleanly):
docker compose down
docker compose up -d
docker compose ps
Verify the bindings landed on the right interface:
sudo ss -tlpn | grep -E ':(25|110|143|465|587|993|995|4190|8005|8443)\b'
You should see 100.64.0.3 (not 0.0.0.0) next to each mail/web port. 19991, 13306 and 7654 should still show 127.0.0.1.
6. Home server firewall — and why ufw isn't the right tool here
The obvious instinct is a ufw rule allowing only the VPS:
# This does NOT actually filter the mailcow ports — see below
sudo ufw allow from <VPS_TAILSCALE_IP> to any port 25,465,587,993,995,4190,8005,8443 proto tcp
This gives false confidence. Docker publishes container ports by inserting its own rules into the DOCKER iptables chain, which is evaluated before ufw's INPUT chain — so ufw allow/deny rules do not govern published container ports at all. A ufw deny here wouldn't block anything.
What's actually limiting exposure is the work you already did in Section 4/5: binding every mail port to the Tailscale IP (100.64.0.3) means they only exist on the tailnet interface, unreachable from the LAN or the internet. The correct place to then restrict which tailnet peers may reach them is a Tailscale ACL, not a host firewall. In your tailnet policy file, scope the mail ports to the VPS only — for example:
// Tailscale ACL — only the VPS may reach the mail/UI ports on the home server
{
"acls": [
{
"action": "accept",
"src": ["<vps-node>"],
"dst": ["<home-node>:25,465,587,993,995,4190,8005,8443"]
}
]
}
(Replace <vps-node>/<home-node> with the device names or tags from your tailnet.) This is enforced by Tailscale regardless of Docker's iptables behaviour.
If you do want host-level enforcement as defence in depth, add rules to the DOCKER-USER chain (which Docker leaves for exactly this) rather than ufw:
# Debian 13, nftables-backed iptables — verify against `iptables -L DOCKER-USER`
sudo iptables -I DOCKER-USER -i tailscale0 -s <VPS_TAILSCALE_IP> -j RETURN
sudo iptables -I DOCKER-USER -i tailscale0 -j DROP
Persist these (e.g. netfilter-persistent save) — DOCKER-USER rules are not saved across reboots by default.
7. Start Mailcow
docker compose pull
docker compose up -d
docker compose ps
You should see 15+ containers reporting Up.
8. VPS side — Caddy and HAProxy (inbound)
Caddy (web UI / webmail / autodiscover — HTTPS termination)
Add a new site block to your existing Caddyfile. Proxy to mailcow's HTTPS port (8443), not the HTTP port (8005): mailcow's nginx redirects HTTP→HTTPS on the 8005 listener and only honours X-Forwarded-Proto from a proxy source it trusts (the Docker gateway 172.22.1.1) — a connection arriving over Tailscale isn't trusted, so proxying to 8005 produces an infinite redirect loop (ERR_TOO_MANY_REDIRECTS). The 8443 listener serves the app directly with no redirect in the path.
mail.seabee.me {
reverse_proxy https://100.64.0.3:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
autodiscover.seabee.me, autoconfig.seabee.me {
reverse_proxy https://100.64.0.3:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
tls_insecure_skip_verify is safe here: the Caddy→cbcore hop is inside the encrypted Tailscale tunnel, and mailcow presents its snake-oil cert on 8443 until the DNS-01 cert (Section 4a) lands. The browser never sees that cert — it only ever talks to Caddy, which serves its own valid Let's Encrypt cert for mail.seabee.me. Caddy preserves the original Host header by default, which is what mailcow needs to select the right vhost. Caddy still handles ACME issuance/renewal for the public-facing cert automatically.
HAProxy (raw mail ports — TCP passthrough with PROXY protocol)
Two changes from the naive version:
- Bind to the VPS public IP, not
*.bind *:25would occupy port 25 on every interface including the Tailscale one — which collides with the outbound relay Postfix we add in Section 9 (it needs<VPS_TAILSCALE_IP>:25). Scoping HAProxy to the public IP keeps inbound and outbound cleanly separated. - Emit PROXY protocol to the backend with
send-proxy-v2, so the home-side Postfix/Dovecot see the real remote IP instead of the VPS's. Without this, mailcow's fail2ban bans the VPS on the first brute-force burst and kills all inbound mail, and Rspamd loses its RBL/reputation signal.
Replace <VPS_PUBLIC_IP> with your VPS's public IPv4 (203.57.114.42 in this guide's DNS section).
frontend smtp_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:25
default_backend smtp_home
backend smtp_home
mode tcp
server home 100.64.0.3:25 check send-proxy-v2
frontend smtp_submission_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:587
default_backend smtp_submission_home
backend smtp_submission_home
mode tcp
server home 100.64.0.3:587 check send-proxy-v2
frontend smtps_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:465
default_backend smtps_home
backend smtps_home
mode tcp
server home 100.64.0.3:465 check send-proxy-v2
frontend imaps_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:993
default_backend imaps_home
backend imaps_home
mode tcp
server home 100.64.0.3:993 check send-proxy-v2
frontend pop3s_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:995
default_backend pop3s_home
backend pop3s_home
mode tcp
server home 100.64.0.3:995 check send-proxy-v2
frontend sieve_in
mode tcp
option tcplog
bind <VPS_PUBLIC_IP>:4190
default_backend sieve_home
backend sieve_home
mode tcp
server home 100.64.0.3:4190 check send-proxy-v2
Reload HAProxy:
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
check send-proxy-v2caveat: withsend-proxy-v2, HAProxy's healthcheckalso speaks PROXY protocol to the backend. That's fine once the home side expects it (below), but during bring-up — before you've configured the home side — the backend will reject the PROXY header and the server shows as DOWN. So enable both ends together (next step), or bring the whole path up first with plain passthrough (nosend-proxy-v2,bind *:PORT) and switch both ends in a single change once mail flows.
Home side — teach Postfix/Dovecot to accept PROXY protocol
mailcow persists these overrides in data/conf/, so they survive ./update.sh. Set the trusted source to the VPS Tailscale IP (100.64.0.2 in this guide) — the only thing that will ever send a PROXY header. Run these from your mailcow dir on cbcore:
cd /home/conor/Docker/mailcow
# Postfix: postscreen handles port 25
grep -q postscreen_upstream_proxy_protocol data/conf/postfix/extra.cf 2>/dev/null || \
echo 'postscreen_upstream_proxy_protocol = haproxy' | tee -a data/conf/postfix/extra.cf
# Dovecot: trust the VPS for PROXY headers on imaps/pop3s/sieve
tee -a data/conf/dovecot/extra.conf >/dev/null <<'EOF'
haproxy_trusted_networks = 100.64.0.2
haproxy_timeout = 30s
EOF
The submission/smtps ports (587/465) and the Dovecot listeners also each need a per-service flag — -o smtpd_upstream_proxy_protocol=haproxy on those Postfix master.cf services, and haproxy = yes on the Dovecot imaps/pop3s/sieve inet_listener blocks.
Verify before editing master.cf / Dovecot listeners: the exact form of those per-service edits differs between mailcow releases, and
master.cf/Dovecot listener blocks aren't as cleanly overridable as theextra.*files — check the current method against docs.mailcow.email and your runningdata/conf/postfix/master.cfrather than pasting blind. Then restart:docker compose restart postfix-mailcow dovecot-mailcow.
VPS firewall
sudo ufw allow 25,465,587,993,995,4190/tcp
(80/443 should already be open for Caddy. Note the ufw caveat from Section 6 applies only to the home server where Docker owns the chains — on the VPS, HAProxy/Postfix are host processes, so ufw governs them normally.)
9. Outbound mail — relay through the VPS
Why this is mandatory here. The home server is behind CGNAT, so if mailcow sends directly, mail leaves via your ISP's shared IP: port 25 outbound is usually blocked, the PTR isn't yours, and it fails SPF (your SPF authorises the VPS IP via mx, not the CGNAT IP). Routing outbound through the VPS makes the sending IP the VPS's public IP — which has the matching PTR (Section 2) and is covered by SPF. DKIM is unaffected: mailcow signs on the home server before handing the message off, so the relay just forwards an already-signed message.
9a. Send-only Postfix relay on the VPS
Run a minimal Postfix on the VPS that accepts mail only from the home server (over Tailscale) and sends it to the internet. It listens on the Tailscale IP so it doesn't collide with HAProxy (which now owns the public IP, Section 8).
Environment: the config below is for Postfix 3.x on Debian 12/13 (matching your home server; adjust if your VPS runs something else). Verify directives against
postconfand the Debianpostfixman pages before applying — Postfix defaults do shift between majors.
Install:
sudo apt update && sudo apt install -y postfix
# choose "Internet Site" / system mail name mail.seabee.me when prompted
Set the relay-relevant parameters with postconf -e (idempotent and scriptable — it edits main.cf in place, so you can re-run it safely). Replace <VPS_TAILSCALE_IP> with the VPS's Tailscale IP (100.64.0.2 here); 100.64.0.3 is cbcore, the only permitted sender:
sudo postconf -e \
'myhostname = mail.seabee.me' \
'smtp_helo_name = mail.seabee.me' \
'mydestination =' \
'relay_domains =' \
'inet_interfaces = 100.64.0.2, 127.0.0.1' \
'inet_protocols = ipv4' \
'mynetworks = 127.0.0.0/8, 100.64.0.3/32' \
'smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination' \
'smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination' \
'smtpd_helo_required = yes' \
'disable_vrfy_command = yes' \
'smtp_tls_security_level = may' \
'smtp_tls_loglevel = 1'
What these do: mydestination=/relay_domains= empty means it's a pure relay — accepts local mail for nothing and is a backup MX for nothing. inet_interfaces binds it to the Tailscale IP only (not the public IP — that's HAProxy's, Section 8). mynetworks trusts only cbcore, and reject_unauth_destination in both restriction lists is what stops it being an open relay — only cbcore may relay to arbitrary destinations. Apply:
sudo postfix check && sudo systemctl restart postfix
Confirm it's listening on the Tailscale IP only, and is not an open relay:
sudo ss -tlpn | grep ':25' # expect <VPS_TAILSCALE_IP>:25, not 0.0.0.0:25
# open-relay test from a THIRD host on your tailnet (not the home server):
# swaks --to test@gmail.com --server <VPS_TAILSCALE_IP> --from x@evil.example
# → must be REJECTED with "Relay access denied"
Also confirm your VPS provider actually permits outbound port 25 (some block it by default and unblock on request) — otherwise delivery silently fails regardless of config.
9b. Point mailcow at the relay
In the mailcow admin UI → Configuration → Routing → Sender-Dependent Transports (or set it as the domain's relayhost), add:
- Host:
[<VPS_TAILSCALE_IP>]:25— the square brackets disable MX lookup so it goes straight to that host - Username / Password: leave blank (the VPS trusts the home server by Tailscale IP; no SASL)
Assign it to seabee.me so all outbound for the domain egresses via the VPS. Send a test message and check the home-server logs show the handoff, then confirm at mail-tester.com that SPF, DKIM and DMARC all pass and the sending IP is the VPS.
Hardening option: if you'd rather not trust by IP (e.g. you expect the tailnet to grow), run the VPS relay on submission (587) with SASL auth instead of
mynetworks, and put credentials in the mailcow relayhost fields. IP-trust over a Tailscale-ACL-restricted link is defensible for a single known peer; SASL is the belt-and-suspenders version.
10. First login
https://mail.seabee.me/admin
Default credentials: admin / moohoo — change this immediately under Configuration → Access → Admin details.
11. 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 10)
- Confirm outbound relay works (Section 9) — mail-tester should show the VPS as the sending IP with SPF/DKIM/DMARC all passing
- Confirm the mail-port cert is real (Section 4a) — the
openssl s_clientcheck in Section 12 should show Let's Encrypt, not the mailcow snake-oil CA; confirm themailcow-cert-pull.timeris enabled (systemctl list-timers | grep cert-pull) - Confirm PROXY protocol is live — check
data/logs/docker compose logs postfix-mailcowshows real client IPs on inbound connections, not the VPS Tailscale IP - Tighten DMARC once aggregate reports look clean:
p=none→p=quarantine→p=reject(Section 2) - 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. Because all your interface bindings now live inmailcow.conf(not a compose override),./update.shcan overwritedocker-compose.ymlfreely without touching your port config.
12. 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
Verify the presented certificate is real, not self-signed (catches the Section 4a failure mode — a snakeoil cert on the mail ports). The issuer should be Let's Encrypt and the dates current:
openssl s_client -connect mail.seabee.me:993 2>/dev/null </dev/null \
| openssl x509 -noout -issuer -subject -dates
# issuer= ... Let's Encrypt ... subject=CN=mail.seabee.me notAfter in the future
openssl s_client -starttls smtp -connect mail.seabee.me:587 2>/dev/null </dev/null \
| openssl x509 -noout -issuer
If the issuer shows the mailcow self-signed CA, the cert sync hasn't landed — on cbcore run /usr/local/bin/pull-mailcow-cert.sh by hand and read its output (Section 4a). On the DNS-01 alternative, check docker compose logs acme-mailcow instead.
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:8443 (HTTPS, verify skipped) |
| 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 |
Appendix: interface bindings at a glance
All host-interface scoping is done in mailcow.conf — no override file at all with the default cert setup (Option B, Section 4a). A docker-compose.override.yml only appears if you choose the DNS-01 alternative, and even then only to pass the ACME provider credentials to acme-mailcow — never for ports.
| Variable | Value | Notes |
|---|---|---|
HTTP_BIND / HTTP_PORT |
100.64.0.3 / 8005 |
Separate bind+port pair (http quirk) |
HTTPS_BIND / HTTPS_PORT |
100.64.0.3 / 8443 |
Separate bind+port pair |
SMTP_PORT |
100.64.0.3:25 |
Single IP:PORT form |
SMTPS_PORT |
100.64.0.3:465 |
|
SUBMISSION_PORT |
100.64.0.3:587 |
|
IMAP_PORT |
100.64.0.3:143 |
|
IMAPS_PORT |
100.64.0.3:993 |
|
POP_PORT |
100.64.0.3:110 |
|
POPS_PORT |
100.64.0.3:995 |
|
SIEVE_PORT |
100.64.0.3:4190 |
|
DOVEADM_PORT |
127.0.0.1:19991 |
Leave local-only |
SQL_PORT |
127.0.0.1:13306 |
Leave local-only |
REDIS_PORT |
127.0.0.1:7654 |
Leave local-only |