Skip to content

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:

HAProxy mail listeners
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.

OpenTofu configuration
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

Apply OpenTofu configuration
tofu apply

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:

/opt/mailcow-dockerized/docker-compose.override.yml
##
## 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:

/opt/mailcow-dockerized/data/conf/postfix/extra.cf
myhostname = relay.benoit.jp.net

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:

Generate deploy keypair (Sakura VPS)
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:

/usr/local/bin/mailcow-cert-deploy.sh (Mailcow VM)
#!/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."
Make it executable (Mailcow VM)
chmod 700 /usr/local/bin/mailcow-cert-deploy.sh

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:

/root/.ssh/authorized_keys (Mailcow VM)
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:

/etc/letsencrypt/renewal-hooks/deploy/mailcow.sh (Sakura VPS)
#!/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
Make it executable (Sakura VPS)
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mailcow.sh

Test

Run a dry-run to verify the full chain works before the next scheduled renewal:

Test deploy hook manually (Sakura VPS)
/etc/letsencrypt/renewal-hooks/deploy/mailcow.sh

Related Documentation: