Raja's Exocortex

Exim4 in Docker

A self-hosted mail server sends email without depending on a third-party relay like SendGrid, SES, or Mailgun. Exim4 in direct-delivery mode connects to recipient MX servers itself β€” no middleman, no relay costs, and full control over headers, DKIM signing, sender enforcement, and rate limiting.

This is useful when:

The Docker setup uses a single raw exim.conf.template rendered at container startup via envsubst. This avoids the Debian split-config toolchain and makes the full configuration visible in one file.

This setup is for outbound sending only. It does not cover inbound mail.

Oracle Cloud permanently blocks outbound port 25 at the network level and cannot be unblocked. Direct mail delivery is not supported on Oracle Cloud.

For AWS EC2 instances, read the exim4-docker section at the end before starting β€” port 25 and reverse DNS require AWS approval upfront and can take time.

Step 1: Prerequisites

Step 2: DNS setup

Create these records before starting. SPF and DKIM records are required for deliverability β€” without them most receiving servers will reject or junk the mail.

# A record: resolves the mail hostname to the server IP
mail.example.com.             IN A    a.a.a.a

# SPF: authorises mail.example.com to send on behalf of example.com
example.com.                  IN TXT  "v=spf1 a:mail.example.com -all"

# DKIM: public key used by receiving servers to verify the DKIM signature
# Populate the p= value after generating the key pair below
mail._domainkey.example.com.  IN TXT  "v=DKIM1; k=rsa; p=PASTE_PUBLIC_KEY_HERE"

Step 3: TLS with certbot

This guide assumes a Let's Encrypt certificate already exists at:

/etc/letsencrypt/live/mail.example.com/fullchain.pem
/etc/letsencrypt/live/mail.example.com/privkey.pem

The container mounts the host certificate directory. The certificate hostname must match what clients connect to (mail.example.com). Exim presents this certificate on ports 587 (STARTTLS) and 465 (implicit TLS).

ReferΒ certbot-dockerΒ to run certbot service on docker.

Step 4: DKIM keys

Generate the private key, lock down its permissions, and extract the public key to publish in DNS. Keys are kept under config/dkim/ inside the project directory and mounted into the container:

# Create the dkim directory inside the project config folder
mkdir -p config/dkim

# Generate a 2048-bit RSA private key and restrict access
openssl genrsa -out config/dkim/example.com.private 2048
chmod 600 config/dkim/example.com.private

# Extract the public key from the private key
openssl rsa -in config/dkim/example.com.private -pubout -out config/dkim/example.com.public.pem

# Print the public key β€” copy this into the DNS DKIM record
cat config/dkim/example.com.public.pem

Strip the -----BEGIN PUBLIC KEY----- header and footer from the output, join the base64 lines into one string, and paste it into the p= field of the DKIM DNS record.

Step 5: Project layout

Create a directory for the mail server, such as /srv/mail.example.com/, and keep all files there:

/srv/mail.example.com/
β”œβ”€β”€ docker-compose.yaml
β”œβ”€β”€ Dockerfile
β”œβ”€β”€ entrypoint.sh
β”œβ”€β”€ healthcheck.sh
└── config/
    β”œβ”€β”€ exim.conf.template
    β”œβ”€β”€ passwd
    β”œβ”€β”€ sender_map
    └── dkim/
        └── example.com.private

Step 6: Files

File: docker-compose.yaml

# Filename: /srv/mail.example.com/docker-compose.yaml
# Purpose: Running exim4 ubuntu on docker for sending direct email delivery

name: mail-example-com

services:
  mail:
    build: .
    container_name: mail.example.com
    restart: always

    ports:
      - "587:587"
      - "465:465"

    environment:
      - DOMAIN=example.com
      - HOSTNAME=mail.example.com

      # DKIM selector must match the DNS record: mail._domainkey.example.com
      - DKIM_SELECTOR=mail

      # Maximum messages per authenticated account per minute
      - RATE_LIMIT=20

      # Restrict which recipient domains are accepted; use * to allow all
      - ALLOWED_RECIPIENT_DOMAINS=*

      # Path inside the container where TLS cert files are found
      - TLS_CERT_DIR=/etc/letsencrypt/live/mail.example.com

    volumes:
      - ./config:/config
      - ./certs:/etc/letsencrypt

    healthcheck:
      test: ["CMD", "/healthcheck.sh"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 60s

File: Dockerfile

# Filename: Dockerfile

FROM ubuntu:24.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt update && apt install -y \
    exim4 \
    gettext \
    swaks \
    && apt clean

COPY entrypoint.sh /entrypoint.sh
COPY healthcheck.sh /healthcheck.sh

RUN chmod +x /entrypoint.sh /healthcheck.sh

CMD ["/entrypoint.sh"]

File: entrypoint.sh

#!/bin/bash
set -e

# Resolve cert file paths from the directory env var so they can be substituted
# into the Exim config template as full file paths
TLS_CERT_FILE="$TLS_CERT_DIR/fullchain.pem"
TLS_KEY_FILE="$TLS_CERT_DIR/privkey.pem"

# Render the Exim config from the template, substituting all env vars
# Only the listed variables are substituted; others in the template are left as-is
echo "[INFO] Generating Exim config..."
envsubst '${DOMAIN} ${HOSTNAME} ${DKIM_SELECTOR} ${RATE_LIMIT} ${TLS_CERT_FILE} ${TLS_KEY_FILE} ${ALLOWED_RECIPIENT_DOMAINS}' \
  < /config/exim.conf.template > /etc/exim4/exim4.conf

# Create the log directory and files with correct ownership before Exim starts
# Exim runs as Debian-exim and will fail to start if it cannot write logs
mkdir -p /var/log/exim
chown -R Debian-exim:Debian-exim /var/log/exim
chmod 755 /var/log/exim
touch /var/log/exim/main.log /var/log/exim/reject.log /var/log/exim/panic.log
chown Debian-exim:Debian-exim /var/log/exim/*.log
chmod 640 /var/log/exim/*.log

# Validate the rendered config before starting β€” exits non-zero if there are errors
echo "[INFO] Validating Exim config..."
exim -bV

# Run Exim in the foreground so Docker can manage the process lifecycle
# -bdf: daemon mode in foreground; -q15m: run the queue every 15 minutes
echo "[INFO] Starting Exim..."
exec exim -bdf -q15m

File: healthcheck.sh

#!/bin/bash

# Connect to the local SMTP port and quit after the EHLO exchange
# This confirms Exim is running and accepting connections
# --quit-after HELO exits cleanly without sending a message
swaks --server 127.0.0.1:587 \
  --timeout 5 \
  --quit-after HELO >/dev/null 2>&1

if [ $? -ne 0 ]; then
  echo "SMTP not responding"
  exit 1
fi

exit 0

File: config/exim.conf.template

# Hostname Exim announces in EHLO β€” must match the PTR record for the server IP
primary_hostname = ${HOSTNAME}

# Accept mail on 587 (STARTTLS) and 465 (implicit TLS / SMTPS)
daemon_smtp_ports = 587 : 465

# Port 465 wraps the entire connection in TLS from the start (no STARTTLS negotiation)
tls_on_connect_ports = 465

# Run the acl_check_rcpt ACL on every RCPT TO command
acl_smtp_rcpt = acl_check_rcpt

# Domains this server will relay mail to; set via ALLOWED_RECIPIENT_DOMAINS env var
# Use * to allow delivery to any domain, or list specific domains separated by colons
domainlist relay_to_domains = ${ALLOWED_RECIPIENT_DOMAINS}

# TLS certificate and key β€” mounted from the host certbot directory
tls_certificate = ${TLS_CERT_FILE}
tls_privatekey = ${TLS_KEY_FILE}

# Log files: main.log (all activity), reject.log (denied messages), panic.log (errors)
log_file_path = /var/log/exim/%s.log

# Concurrency limits to protect the server under load
queue_run_max = 5
remote_max_parallel = 10
smtp_accept_max = 50
smtp_accept_max_per_host = 10

# Only advertise AUTH when TLS is active β€” prevents credentials being sent in cleartext
auth_advertise_hosts = ${if eq{$tls_cipher}{}{}{*}}

begin acl

acl_check_rcpt:

  # Reject if TLS is not active β€” submission without encryption is not allowed
  deny message = TLS required
       condition = ${if eq{$tls_cipher}{}{yes}{no}}

  # Reject unauthenticated senders β€” this server is submission-only, not an open relay
  deny message = Authentication required
       !authenticated = *

  # Reject if the sender address does not match the account's entry in sender_map
  # Prevents one account from spoofing another account's address
  deny message = Sender not allowed
       !condition = ${if eq{$sender_address}{${lookup{$authenticated_id}lsearch{/config/sender_map}{$value}{}}}{yes}{no}}

  # Reject if the recipient domain is not in the allowed list
  deny message = Recipient domain not allowed
       !domains = +relay_to_domains

  # Reject if the account has exceeded its per-minute message quota
  # RATE_LIMIT is set via env var; adjust the unit (1m / 1h) based on volume
  deny message = Rate limit exceeded
       ratelimit = ${RATE_LIMIT}/1m/strict/$authenticated_id

  accept

begin authenticators

# PLAIN auth: client sends username and password in one shot
# Looks up the username in /config/passwd and compares the hashed password
plain:
  driver = plaintext
  public_name = PLAIN
  server_condition = "${if crypteq{$auth3}{${extract{1}{:}{${lookup{$auth2}lsearch{/config/passwd}{$value}{*:*}}}}}{1}{0}}"
  server_set_id = $auth2

# LOGIN auth: client sends username and password as separate prompts
# Same lookup as PLAIN but uses $auth1 (username) and $auth2 (password)
login:
  driver = plaintext
  public_name = LOGIN
  server_prompts = "Username:: : Password::"
  server_condition = "${if crypteq{$auth2}{${extract{1}{:}{${lookup{$auth1}lsearch{/config/passwd}{$value}{*:*}}}}}{1}{0}}"
  server_set_id = $auth1

begin routers

# Route outbound messages by looking up the recipient domain's MX record in DNS
# no_more: stop processing routers after this one matches
dnslookup:
  driver = dnslookup
  domains = +relay_to_domains
  transport = remote_smtp
  no_more

begin transports

# Deliver mail over SMTP with DKIM signing
# The private key is mounted from config/dkim/ on the host
remote_smtp:
  driver = smtp
  dkim_domain = ${DOMAIN}
  dkim_selector = ${DKIM_SELECTOR}
  dkim_private_key = /config/dkim/${DOMAIN}.private
  hosts_try_fastopen = *

begin retry

# Retry schedule: retry every 15m for 2h, then back off geometrically up to 16h, then every 6h for 4 days
*                      *           F,2h,15m; G,16h,1h,1.5; F,4d,6h

File: config/passwd

# SMTP auth credentials β€” one account per line, format: username:hashed_password
# Generate hashes with: docker run --rm -it ubuntu exim4 passwd <username>
# Or on any system with exim4 installed: exim4 passwd <username>
app:$6$rounds=656000$REPLACE_WITH_REAL_HASH
alerts:$6$rounds=656000$REPLACE_WITH_REAL_HASH

File: config/sender_map

# Maps each SMTP auth username to the one sender address it is allowed to use
# The ACL checks this: if the From address does not match, the message is rejected
app:app@example.com
alerts:alerts@example.com

Step 7: Start the container

Build and start the container. The entrypoint renders the Exim config from the template, validates it, and starts Exim in the foreground:

docker compose up -d
docker compose logs -f

Verify Exim is running and the healthcheck passes:

docker compose ps
docker exec mail.example.com exim -bV

AWS EC2

If this mail server runs on AWS EC2, do this before starting the container. AWS blocks outbound port 25 and does not set reverse DNS by default β€” both must be requested and approved before mail can be delivered.

Before submitting the request, create the DNS A record for mail.example.com. AWS validates that the hostname resolves before approving the PTR record.

Submit the request at: https://support.console.aws.amazon.com/support/contacts#/rdns-limits

Request both:

Recommended values for the form:

EC2 instance ID : i-0123456789abcdef0
Elastic IP      : a.a.a.a
PTR record      : mail.example.com
Use case        : transactional and system outbound mail from example.com

Do not continue until AWS has approved both the PTR record and port 25 removal. Approval typically takes one business day.

References:

  1. exim4-ubuntu - Exim4 native setup on Ubuntu
  2. certbot-docker - Running certbot service in docker