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:
- Services generate automated mail (monitoring alerts, cron output, account notifications)
- A fixed sender address must be enforced per authenticated account so one credential cannot spoof another
- DKIM-signed mail is needed under a specific domain without routing through a third party
- Per-message pricing at scale needs to be avoided
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
- Static public IP
- Reverse DNS (PTR) for the mail host pointing to
mail.example.com - Outbound port
25open to the internet - Inbound ports
587and465open if clients submit mail remotely - A hostname such as
mail.example.com - Docker and Docker Compose installed on the host
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.pemThe 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.pemStrip 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.privateStep 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: 60sFile: 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 -q15mFile: 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 0File: 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,6hFile: 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_HASHFile: 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.comStep 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 -fVerify Exim is running and the healthcheck passes:
docker compose ps
docker exec mail.example.com exim -bVAWS 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:
- Reverse DNS (PTR) for the instance public IP β
mail.example.com - Removal of the outbound port
25restriction
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.comDo not continue until AWS has approved both the PTR record and port 25 removal. Approval typically takes one business day.
References:
- exim4-ubuntu - Exim4 native setup on Ubuntu
- certbot-docker - Running certbot service in docker