🐳 Docker Setup

Quick Start

docker pull ghcr.io/themadorg/madmail:latest

Image Tags

Tag Description
latest Latest stable version from the main branch
X.Y.Z Specific version (e.g., 0.18.0)

Method 1: Simple with IP (SQLite)

docker run -d --name madmail \ -p 25:25 \ -p 143:143 \ -p 465:465 \ -p 587:587 \ -p 993:993 \ -p 80:80 \ -p 443:443 \ -p 1080:1080 \ -v ./maddy.conf:/data/maddy.conf \ -v ./data:/data \ -v ./tls:/data/tls \ -e MADDY_HOSTNAME=SERVER_IP \ -e MADDY_DOMAIN="[SERVER_IP]" \ -e MADDY_PUBLIC_IP=SERVER_IP \ ghcr.io/themadorg/madmail:latest

You need a docker-compose.yml and a maddy.conf file. In this mode, self-signed TLS certificates are automatically generated.

docker-compose.yml
services:
  madmail:
    image: ghcr.io/themadorg/madmail:latest
    restart: always
    ports:
      - "25:25"      # SMTP
      - "143:143"    # IMAP (STARTTLS)
      - "465:465"    # Submission (TLS/SSL)
      - "587:587"    # Submission (STARTTLS)
      - "993:993"    # IMAPS (TLS/SSL)
      - "80:80"      # HTTP
      - "443:443"    # HTTPS
      - "1080:1080"  # Shadowsocks
    volumes:
      - ./maddy.conf:/data/maddy.conf
      - ./data:/data
      - ./tls:/data/tls
    environment:
      - MADDY_HOSTNAME=SERVER_IP
      - MADDY_DOMAIN=[SERVER_IP]
      - MADDY_PUBLIC_IP=SERVER_IP
      - MADDY_SQLITE_UNSAFE_SYNC_OFF=1
maddy.conf
$(hostname) = {env:MADDY_HOSTNAME}
$(primary_domain) = {env:MADDY_DOMAIN}
$(local_domains) = $(primary_domain) $(hostname)
$(public_ip) = {env:MADDY_PUBLIC_IP}

tls file /data/tls/fullchain.pem /data/tls/privkey.pem

state_dir /data

auth.pass_table local_authdb {
    auto_create yes
    table sql_table {
        driver sqlite3
        dsn /data/credentials.db
        table_name passwords
    }
}

storage.imapsql local_mailboxes {
    auto_create yes
    driver sqlite3
    dsn /data/imapsql.db
}

hostname $(hostname)

table.chain local_rewrites {
    optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
    optional_step static {
        entry postmaster postmaster@$(primary_domain)
    }
}

msgpipeline local_routing {
    destination postmaster $(local_domains) {
        modify {
            replace_rcpt &local_rewrites
        }
        deliver_to &local_mailboxes
    }
    default_destination {
        reject 550 5.1.1 "User doesn't exist"
    }
}

smtp tcp://0.0.0.0:25 {
    dmarc yes
    check {
        require_mx_record
        dkim
        spf
    }
    source $(local_domains) {
        reject 501 5.1.8 "Use Submission for outgoing SMTP"
    }
    default_source {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.1.1 "User doesn't exist"
        }
    }
}

submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
    auth &local_authdb
    tls file /data/tls/fullchain.pem /data/tls/privkey.pem
    source $(local_domains) {
        check {
            authorize_sender {
                prepare_email &local_rewrites
                user_to_email identity
            }
        }
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            modify {
                dkim $(primary_domain) $(local_domains) default
            }
            deliver_to &remote_queue
        }
    }
    default_source {
        reject 501 5.1.8 "Non-local sender domain"
    }
}

target.remote outbound_delivery {
    mx_auth {
        dane
        mtasts {
            cache fs
            fs_dir mtasts_cache/
        }
        local_policy {
            min_tls_level encrypted
            min_mx_level none
        }
    }
}

target.queue remote_queue {
    target &outbound_delivery
    autogenerated_msg_domain $(primary_domain)
    bounce {
        destination postmaster $(local_domains) {
            deliver_to &local_routing
        }
        default_destination {
            reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
        }
    }
}

imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
    auth &local_authdb
    storage &local_mailboxes
    tls file /data/tls/fullchain.pem /data/tls/privkey.pem
}

chatmail tls://0.0.0.0:443 tcp://0.0.0.0:80 {
    mail_domain $(primary_domain)
    mx_domain $(hostname)
    web_domain $(hostname)
    public_ip $(public_ip)
    auth_db local_authdb
    storage local_mailboxes
    tls file /data/tls/fullchain.pem /data/tls/privkey.pem
    ss_addr 0.0.0.0:1080
}
Note: When using an IP instead of a domain, you must wrap it in brackets in MADDY_DOMAIN: [203.0.113.50]

Method 2: With PostgreSQL

The database is locked error in SQLite is resolved by using PostgreSQL. Also, pub/sub for real-time IMAP updates becomes available.

docker-compose.yml
services:
  madmail:
    image: ghcr.io/themadorg/madmail:latest
    restart: always
    ports:
      - "25:25"
      - "143:143"
      - "465:465"
      - "587:587"
      - "993:993"
      - "80:80"
      - "443:443"
      - "1080:1080"
    volumes:
      - ./maddy.conf:/data/maddy.conf
      - ./data:/data
      - ./tls:/data/tls
    environment:
      - MADDY_HOSTNAME=SERVER_IP
      - MADDY_DOMAIN=[SERVER_IP]
      - MADDY_PUBLIC_IP=SERVER_IP
      - POSTGRES_USER=madmail
      - POSTGRES_PASSWORD=madmail_pass
      - POSTGRES_DB=madmail
    depends_on:
      - db

  db:
    image: postgres:alpine
    restart: always
    environment:
      - POSTGRES_USER=madmail
      - POSTGRES_PASSWORD=madmail_pass
      - POSTGRES_DB=madmail
    volumes:
      - ./pgdata:/var/lib/postgresql/data

Method 3: Domain with Automatic TLS (Let's Encrypt)

⚠️ Prerequisites:
docker-compose.yml
services:
  madmail:
    image: ghcr.io/themadorg/madmail:latest
    restart: always
    ports:
      - "25:25"
      - "143:143"
      - "465:465"
      - "587:587"
      - "993:993"
      - "80:80"
      - "443:443"
      - "1080:1080"
    volumes:
      - ./maddy.conf:/data/maddy.conf
      - ./data:/data
    environment:
      - MADDY_HOSTNAME=chat.example.org
      - MADDY_DOMAIN=chat.example.org
      - MADDY_PUBLIC_IP=203.0.113.50
      - MADDY_ACME_EMAIL=admin@example.org
How it works:
  1. Madmail starts an HTTP server on port 80 for the ACME challenge
  2. Let's Encrypt issues a certificate on the first TLS connection
  3. Certificates are stored in /data/autocert/ and auto-renewed
  4. Non-ACME HTTP requests are redirected to HTTPS (302)
Alternative: If you have your own certificate (e.g., from certbot):
tls file /data/tls/fullchain.pem /data/tls/privkey.pem

Ports

Port Protocol Description
25SMTPIncoming/Outgoing email
143IMAPReading email (STARTTLS)
465SubmissionOutgoing email (TLS/SSL)
587SubmissionOutgoing email (STARTTLS)
993IMAPSReading email (TLS/SSL)
80HTTPRegistration + ACME challenge
443HTTPSRegistration + ALPN mux
1080SOCKS5Shadowsocks proxy

Volumes

Container Path Description
/data/maddy.confServer configuration file
/dataState directory (databases, keys, certs)
/data/tlsTLS certificate files

Building from Source

Dockerfile
docker build -t madmail .
Compose Build
docker compose up -d --build

Docker images are automatically built and pushed to GHCR on every push to the main branch.