Mailcow¶
Full-Featured Mail Server
Mailcow is a dockerized mail server suite combining Postfix, Dovecot, SOGo, and a web UI. It handles SMTP, IMAP, and ManageSieve behind HAProxy on the Sakura VPS, which forwards mail traffic using the PROXY protocol so Mailcow sees real client IPs.
Architecture¶
Traffic flows from the public internet through HAProxy on the Sakura VPS, which forwards mail ports to the Mailcow VM running inside Incus on the home server. The VPS reaches the home server through a Tailscale WireGuard tunnel (see Infrastructure Overview for details). HAProxy uses the PROXY protocol (send-proxy) so Postfix and Dovecot receive the original client IP.
flowchart LR
Internet(["๐ Internet"])
subgraph vps["โ๏ธ Sakura VPS"]
direction TB
HAProxy["๐ HAProxy"]
p443[":443 HTTPS"]
p25[":25 SMTP"]
p465[":465 SMTPS"]
p993[":993 IMAPS"]
p4190[":4190 Sieve"]
end
subgraph incus["๐ฆ Incus VM โ mailcow (Ubuntu 24.04)"]
direction TB
subgraph docker["๐ณ Docker Compose"]
direction TB
nginx["๐ nginx-mailcow\n:80"]
postfix["๐ค postfix-mailcow\n:10025 ยท :10465"]
dovecot["๐ฅ dovecot-mailcow\n:10993 ยท :14190"]
rspamd["๐ก๏ธ rspamd-mailcow\nSpam Filter"]
sogo["๐ sogo-mailcow\nWebmail"]
mariadb[("๐๏ธ mariadb-mailcow")]
redis[("โก redis-mailcow")]
end
end
Internet -->|"mail ports"| HAProxy
HAProxy --- p443 & p25 & p465 & p993 & p4190
p443 -->|"HTTP ยท SSL terminated\nTailscale tunnel"| nginx
p25 & p465 -->|"PROXY protocol\nTailscale tunnel"| postfix
p993 & p4190 -->|"PROXY protocol\nTailscale tunnel"| dovecot
nginx --> sogo
postfix <-->|"spam check"| rspamd
postfix & dovecot & sogo <--> mariadb
dovecot <--> redis
| Port | Protocol | HAProxy listener | Mailcow port |
|---|---|---|---|
| 25 | TCP | listen smtp |
10025 (Postfix, PROXY) |
| 465 | TCP | listen smtps |
10465 (Postfix, PROXY) |
| 993 | TCP | listen imaps |
10993 (Dovecot, PROXY) |
| 4190 | TCP | listen sieve |
14190 (Dovecot, PROXY) |
Install¶
HAProxy Configuration¶
The Sakura VPS forwards all mail ports to the Mailcow VM at 10.10.10.214:
listen smtp
bind :25
bind :::25 v6only
mode tcp
option tcplog
server mailcow 10.10.10.214:10025 send-proxy check port 10025
listen smtps
bind :465
bind :::465 v6only
mode tcp
option tcplog
server mailcow 10.10.10.214:10465 send-proxy check port 10465
listen imaps
bind :993
bind :::993 v6only
mode tcp
option tcplog
server mailcow 10.10.10.214:10993 send-proxy check port 10993
listen sieve
bind :4190
bind :::4190 v6only
mode tcp
option tcplog
server mailcow 10.10.10.214:14190 send-proxy check port 14190
Infrastructure Configuration¶
Infrastructure as Code
The canonical configuration is maintained at Benoit/OpenTofu.
The Mailcow VM is provisioned with OpenTofu. Two extra block volumes are attached: one for Docker data and one for /opt where Mailcow lives.
resource "incus_storage_volume" "mailcow_var_lib_docker" {
name = "mailcow_var_lib_docker"
pool = incus_storage_pool.default.name
content_type = "block"
config = {
"size" = "50GiB"
}
}
resource "incus_storage_volume" "mailcow_opt" {
name = "mailcow_opt"
pool = incus_storage_pool.default.name
content_type = "block"
config = {
"size" = "10GiB"
}
}
resource "incus_instance" "mailcow" {
name = "mailcow"
image = "images:ubuntu/24.04"
type = "virtual-machine"
config = {
"limits.cpu" = 6
"limits.memory" = "6GiB"
}
device {
name = "root"
type = "disk"
properties = {
size = "25GiB"
path = "/"
pool = incus_storage_pool.default.name
}
}
device {
name = "var_lib_docker"
type = "disk"
properties = {
# Mount manually inside the VM: path = "/var/lib/docker"
source = incus_storage_volume.mailcow_var_lib_docker.name
pool = incus_storage_pool.default.name
}
}
device {
name = "opt"
type = "disk"
properties = {
# Mount manually inside the VM: path = "/opt"
source = incus_storage_volume.mailcow_opt.name
pool = incus_storage_pool.default.name
}
}
}
Manual mount required
The var_lib_docker and opt block volumes are attached to the VM but not auto-mounted. After the VM first boots, format and mount them manually before installing Docker or Mailcow.
Deploy Infrastructure¶
Inside the VM: Docker and Mailcow¶
Follow the official Mailcow installation guide to install Docker and clone Mailcow into /opt/mailcow-dockerized. The key customisation is a docker-compose.override.yml that exposes PROXY-protocol-aware ports alongside the standard ones.
PROXY Protocol Override¶
Mailcow's default Docker Compose configuration does not expose PROXY protocol ports. The override adds dedicated ports for HAProxy to connect to:
##
## Set haproxy_trusted_networks in Dovecot's extra.conf!
##
services:
dovecot-mailcow:
ports:
- "${IMAP_PORT_HAPROXY:-0.0.0.0:10143}:10143"
- "${IMAPS_PORT_HAPROXY:-0.0.0.0:10993}:10993"
- "${POP_PORT_HAPROXY:-0.0.0.0:10110}:10110"
- "${POPS_PORT_HAPROXY:-0.0.0.0:10995}:10995"
- "${SIEVE_PORT_HAPROXY:-0.0.0.0:14190}:14190"
postfix-mailcow:
ports:
- "${SMTP_PORT_HAPROXY:-0.0.0.0:10025}:10025"
- "${SMTPS_PORT_HAPROXY:-0.0.0.0:10465}:10465"
- "${SUBMISSION_PORT_HAPROXY:-0.0.0.0:10587}:10587"
environment:
- MAILCOW_HOSTNAME=relay.benoit.jp.net
Port conventions
Ports prefixed with 10 are PROXY-protocol variants of the standard ports (25, 143, 465, 587, 993, 995). Ports prefixed with 14 follow the same pattern for non-standard ports like ManageSieve (4190 โ 14190).
Postfix Hostname¶
The public-facing hostname for Postfix is set via extra.cf so outgoing mail identifies itself correctly:
TLS Certificate Sync¶
TLS is terminated on the Sakura VPS by HAProxy. Certbot manages the Let's Encrypt certificate there for mail.benoit.jp.net. Mailcow still needs that certificate so Postfix and Dovecot can present it to connecting clients.
The VPS pushes the certificate to the Mailcow VM via SSH after each renewal. A restricted authorized_keys entry on the VM ensures the deploy key can only run the cert-deploy script, nothing else.
SSH Key Setup¶
On the Sakura VPS, generate a dedicated keypair for cert deployment:
ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519_mailcow_deploy -N "" -C "mailcow-cert-deploy@relay"
Deploy Script on the Mailcow VM¶
On the Mailcow VM, create a script that reads the certificate and key from stdin (separated by ---), writes them to the Mailcow SSL directory, and reloads the affected services:
#!/bin/bash
set -e
SSL_DIR=/opt/mailcow-dockerized/data/assets/ssl
input=$(cat)
# delete from the "---" separator to end-of-input โ keeps the cert (everything before ---)
cert=$(echo "$input" | sed '/^---$/,$d')
# delete from line 1 to the "---" separator โ keeps the key (everything after ---)
key=$(echo "$input" | sed '1,/^---$/d')
echo "$cert" > "$SSL_DIR/cert.pem"
echo "$key" > "$SSL_DIR/key.pem"
chmod 600 "$SSL_DIR/cert.pem" "$SSL_DIR/key.pem"
cd /opt/mailcow-dockerized
docker compose exec postfix-mailcow postfix reload
docker compose kill -s SIGHUP dovecot-mailcow
echo "Certificate deployed and services reloaded."
Restricted authorized_keys¶
On the Mailcow VM, add the VPS public key to /root/.ssh/authorized_keys with a command= restriction so this key can only invoke the deploy script, and nothing else:
restrict,command="/usr/local/bin/mailcow-cert-deploy.sh" ssh-ed25519 AAAA... mailcow-cert-deploy@relay
What restrict does
The restrict keyword disables port forwarding, X11 forwarding, agent forwarding, and PTY allocation for this key. Combined with command=, the key can only trigger the one deploy script, regardless of what command the caller attempts to run.
Certbot Deploy Hook¶
On the Sakura VPS, place a deploy hook in /etc/letsencrypt/renewal-hooks/deploy/ that pipes both certificate files to the remote script over a single SSH connection:
#!/bin/sh
set -e
{
cat /etc/letsencrypt/live/mail.benoit.jp.net/fullchain.pem
echo "---"
cat /etc/letsencrypt/live/mail.benoit.jp.net/privkey.pem
} | ssh -i /root/.ssh/id_ed25519_mailcow_deploy \
-o StrictHostKeyChecking=accept-new \
root@mailcow.incus
Test¶
Run a dry-run to verify the full chain works before the next scheduled renewal:
Related Documentation:
- Sakura VPS - HAProxy configuration and public-facing infrastructure
- Infrastructure Overview - Complete self-hosting architecture