Self-hosting email is one of those projects that sounds straightforward until you actually attempt it. Between TLS configuration, reverse proxy behavior, Autodiscover requirements, and the expectations of modern mail clients, it is remarkably easy to end up with redirect loops, broken SMTP connections, or certificates that validate everywhere except where you actually need them.
This post walks through a working, real-world setup: running Mailu behind Caddy with Let’s Encrypt certificates issued via DNS-01 validation. It is written from the perspective of what actually works in production — not just what looks correct on paper. Every architectural decision documented here was made in response to a real failure mode.
All domains, paths, IP addresses, and identifiers used in this post are illustrative examples. Replace them with values appropriate to your own environment.
Why This Architecture
Mail servers have requirements that most web applications do not. They need TLS not just for browser traffic but for IMAP and SMTP connections from native clients. They need stable, predictable hostnames that match the certificates exactly. And they must support Autodiscover so that Outlook, Gmail, and mobile mail clients can configure themselves automatically without manual intervention from the user.
The architecture described in this post addresses all three requirements cleanly:
Internet
│
▼
[Caddy :443] ← Public HTTPS — handles TLS termination and certificate management
│
▼
[Mailu Front :443] ← Internal HTTPS — Mailu's nginx front-end, TLS preserved end-to-end
│
├─ IMAP (993) ← Encrypted IMAP for mail clients
├─ SMTP (465) ← Encrypted SMTP submission
└─ Webmail / Admin UI
The single most important design decision in this entire setup is this:
Caddy proxies to Mailu’s internal HTTPS port (443), not HTTP (80).
That one choice eliminates the majority of redirect and TLS issues that plague self-hosted mail setups. The reason is explained in detail in the Caddy configuration section below.
Directory Layout
The following directory structure keeps Mailu, Caddy, and certificate storage cleanly separated. Each component has its own directory and Docker Compose file, making it possible to restart, update, or debug each service independently without affecting the others:
/opt/mail/
├── mailu/
│ ├── data/ ← Mailu persistent data (database, etc.)
│ ├── mail/ ← Actual mail storage (Maildir format)
│ ├── dkim/ ← DKIM private keys
│ ├── certs/
│ │ ├── cert.pem ← Certificate chain (copied from Let's Encrypt)
│ │ └── key.pem ← Private key (copied from Let's Encrypt)
│ ├── docker-compose.yml
│ └── mailu.env
│
├── caddy/
│ ├── Caddyfile
│ └── docker-compose.yml
│
└── letsencrypt/ ← Optional: local copy of certificate state
You can place this layout anywhere — /opt, /srv, or a mounted external volume. What matters is that the paths are consistent between your Docker Compose volume mounts and the paths referenced in your configuration files.
Certificates: Let’s Encrypt with DNS-01 Validation
DNS-01 validation is the correct choice for mail server certificate issuance, for several reasons specific to how mail infrastructure works:
- No open HTTP port required. HTTP-01 validation requires Let’s Encrypt to reach your server on port 80. DNS-01 proves domain ownership through a DNS TXT record, so port 80 does not need to be accessible — and on a mail server, you often do not want it to be.
- Works with multiple subdomains in a single certificate. A single DNS-01 request can cover
mail.example.com,autodiscover.example.com, andautoconfig.example.comsimultaneously. - Fully compatible with reverse proxy architectures. Because validation does not involve the server responding to HTTP requests, the fact that Caddy sits in front of Mailu is irrelevant to the certificate issuance process.
Requesting the Certificate
Replace --dns-provider with the actual Certbot DNS plugin for your DNS host (e.g., --dns-cloudflare, --dns-route53, etc.):
certbot certonly \
--dns-provider \
-d mail.example.com \
-d autodiscover.example.com \
-d autoconfig.example.com
On success, Certbot creates the following files:
/etc/letsencrypt/live/mail.example.com/
├── fullchain.pem ← Certificate + intermediate chain (use this, not cert.pem alone)
└── privkey.pem ← Private key
Making Certificates Available to Mailu
Mailu’s internal nginx front-end expects certificate files to be present inside its own Docker volume, at the paths configured in mailu.env. It cannot read directly from /etc/letsencrypt/live/ because that directory is managed by the host, not the container. The solution is to copy the certificate files into Mailu’s volume after each issuance or renewal.
cp /etc/letsencrypt/live/mail.example.com/fullchain.pem \
/opt/mail/mailu/certs/cert.pem
cp /etc/letsencrypt/live/mail.example.com/privkey.pem \
/opt/mail/mailu/certs/key.pem
chmod 644 /opt/mail/mailu/certs/cert.pem
chmod 600 /opt/mail/mailu/certs/key.pem
The permissions here are intentional and important. The certificate file (cert.pem) is readable by any process that needs it. The private key (key.pem) is readable only by root, preventing other processes or users on the host from accessing it.
Critical: If these files are empty, truncated, or have incorrect permissions, IMAP and SMTP will fail to start or will reject all client connections immediately. If you encounter TLS handshake failures after a fresh setup, verify the file contents and permissions before investigating anything else.
Mailu Configuration
The following settings in mailu.env are the minimum required for Mailu to advertise the correct endpoints to mail clients and to enable Autodiscover:
DOMAIN=example.com
HOSTNAMES=mail.example.com,autodiscover.example.com,autoconfig.example.com
TLS_FLAVOR=cert
ENABLE_AUTODISCOVER=true
WEB_ADMIN=/admin
WEB_WEBMAIL=/webmail
A few of these settings deserve explanation:
HOSTNAMES— This comma-separated list tells Mailu which hostnames it is responsible for. All three must be present so that Mailu’s nginx front-end correctly responds to requests on each subdomain. Missing a hostname here will cause 404 or redirect errors for that subdomain.TLS_FLAVOR=cert— Instructs Mailu to use the certificate files you copied into/opt/mail/mailu/certs/rather than attempting to issue its own certificates. This is required when Caddy handles the public-facing TLS termination.ENABLE_AUTODISCOVER=true— Enables Mailu’s built-in Autodiscover and Autoconfig endpoints, which allow Outlook, Thunderbird, and mobile clients to retrieve their IMAP and SMTP settings automatically.
Caddy: The Reverse Proxy Configuration
The Caddyfile configuration below is the core of this architecture. Read the explanation that follows it before making any modifications.
mail.example.com,
autodiscover.example.com,
autoconfig.example.com {
@root path /
redir @root /webmail 302
reverse_proxy https://mailu-front:443 {
transport http {
tls_insecure_skip_verify
}
header_up Host {host}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Proto https
header_up X-Forwarded-Port 443
}
}
Why Proxying to HTTPS (Not HTTP) Is Essential
Mailu’s internal front-end is nginx, and it is configured to enforce HTTPS. When a request arrives over HTTP, nginx responds with a 301 redirect telling the client to use HTTPS instead. This is entirely correct behavior — but it creates a critical problem when Caddy proxies to port 80.
Here is the failure chain that occurs when you proxy to HTTP:
- A browser requests
https://mail.example.com/admin/. Caddy receives the HTTPS request. - Caddy forwards the request to Mailu on port 80 (HTTP).
- Mailu’s nginx sees an HTTP request and responds with a 301 redirect to HTTPS.
- Caddy forwards the redirect back to the browser.
- The browser follows the redirect and requests
https://mail.example.com/admin/again — the same URL it started with. - Go to step 1. The loop never terminates.
The symptoms of this failure are: infinite 301 redirect loops in the browser, a broken admin UI that never loads, and webmail that returns a redirect error. Many attempts to fix this by adjusting headers or adding X-Forwarded-Proto values will not resolve it, because the root cause is the proxy target port, not the headers.
Proxying to https://mailu-front:443 eliminates this entirely. Mailu receives an HTTPS request, recognizes it as already secure, and serves the response directly without issuing any redirect.
What Each Directive Does
tls_insecure_skip_verify— Caddy does not verify the TLS certificate presented by Mailu’s internal nginx. This is acceptable here because the connection between Caddy and Mailu is on an internal Docker network that is not exposed externally. The public-facing TLS certificate — the one the browser sees — is fully validated by Caddy using Let’s Encrypt.header_up Host {host}— Passes the original request hostname to Mailu so that it can generate correct internal links and redirects. Without this, Mailu may generate links pointing to its internal container hostname rather than your public domain.header_up X-Forwarded-Proto https— Tells Mailu that the original client request arrived over HTTPS, even though the internal Caddy-to-Mailu connection is also HTTPS. This prevents Mailu from generating HTTP-prefixed links in its responses.redir @root /webmail 302— Redirects bare root requests (e.g.,https://mail.example.com/) to the webmail interface, so users who type only the mail hostname land somewhere useful rather than a blank page or 404.
Starting the Stack
Start Mailu first, then Caddy. Caddy will begin proxying to Mailu’s front-end container immediately on startup, so Mailu must be available before Caddy attempts to connect:
cd /opt/mail/mailu
docker compose up -d
cd /opt/mail/caddy
docker compose up -d
Confirm all containers are running and have not exited immediately:
docker ps
If any container shows a status of Exited, check its logs before proceeding: docker logs <container-name>. Common causes at this stage are missing certificate files, incorrect paths in mailu.env, or a port conflict on the host.
Verifying the Web Interfaces
Use curl with the -I flag (headers only) and -k flag (skip local TLS verification) to test each web endpoint. The -L flag follows redirects, which allows you to confirm that the redirect chain terminates rather than looping:
curl -ILk https://mail.example.com/admin/
curl -ILk https://mail.example.com/webmail/
A healthy response will show a final HTTP 200 status with no intermediate 301 responses pointing back to the same URL. If you see a sequence of 301 responses that repeat the same URL, the proxy is still targeting port 80. Verify the reverse_proxy target in your Caddyfile.
Verifying Mail Protocol Connectivity
Use openssl s_client to verify that IMAP and SMTP are accepting TLS connections and presenting the correct certificate. The -brief flag produces concise output showing only the connection result and certificate subject.
IMAP (Port 993)
openssl s_client -connect mail.example.com:993 \
-servername mail.example.com -brief
SMTP Submission (Port 465)
openssl s_client -connect mail.example.com:465 \
-servername mail.example.com -brief
Both commands should complete with a TLS handshake and display the certificate subject matching your domain. A "Connection refused" result indicates that the service is not listening on that port — check that the Mailu IMAP and SMTP containers are running and that the ports are exposed correctly in your Docker Compose file.
A note on port 587: modern Mailu deployments do not support STARTTLS on port 587 by default. Port 465 (implicit TLS, also known as SMTPS) is the correct and recommended submission port for this setup. Configure mail clients accordingly.
Verifying Autodiscover
Autodiscover is the mechanism by which Outlook and many mobile clients retrieve their IMAP and SMTP server settings automatically when a user enters their email address. Verifying it works before distributing email accounts to users saves significant support overhead.
curl -X POST https://autodiscover.example.com/autodiscover/autodiscover.xml \
-H "Content-Type: text/xml" \
--data '
user@example.com
http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a
'
A successful response will be an XML document containing your IMAP server hostname, IMAP port (993), SMTP server hostname, and SMTP port (465). If the response is empty, a 404, or an HTML error page, verify that ENABLE_AUTODISCOVER=true is set in mailu.env and that autodiscover.example.com is included in the HOSTNAMES list.
Automatic Certificate Renewal
Let's Encrypt certificates expire after 90 days. Certbot's --deploy-hook option runs a command only when a certificate is actually renewed (not on every renewal check), making it the correct place to copy updated certificate files and restart the affected Mailu services.
certbot renew --deploy-hook "
cp /etc/letsencrypt/live/mail.example.com/fullchain.pem /opt/mail/mailu/certs/cert.pem &&
cp /etc/letsencrypt/live/mail.example.com/privkey.pem /opt/mail/mailu/certs/key.pem &&
chmod 644 /opt/mail/mailu/certs/cert.pem &&
chmod 600 /opt/mail/mailu/certs/key.pem &&
docker restart mailu-front &&
docker restart mailu-imap &&
docker restart mailu-smtp
"
Place this command in a cron job or systemd timer that runs twice daily — the frequency recommended by Certbot. Restarting all three Mailu services (front-end, IMAP, and SMTP) after renewal ensures that each service reloads the updated certificate files. Restarting only the front-end is insufficient, because IMAP and SMTP hold their own TLS context independently.
Lessons Learned
Each of the following points represents a failure mode that was encountered during the development of this setup. They are documented here so you do not have to discover them the same way:
- Proxying Mailu via HTTP causes infinite redirect loops. Mailu's nginx enforces HTTPS internally. Caddy proxying to port 80 creates a redirect cycle that never resolves. Always proxy to
https://mailu-front:443. - Empty or truncated certificate files break IMAP and SMTP instantly. If the copy step fails partway through — for example, due to a permission error — the resulting zero-byte or partial file will cause TLS initialization to fail on startup. Verify file contents and sizes after every copy operation.
- Port 465 is the correct SMTP submission port for modern Mailu. STARTTLS on port 587 is not reliably supported in current Mailu versions. Configure all mail clients to use port 465 with implicit TLS.
- DNS-01 validation is the correct certificate method for mail servers. HTTP-01 requires port 80 to be open and accessible, which conflicts with how mail server firewall rules are typically structured. DNS-01 requires neither an open port nor any HTTP response from the server.
Final Thoughts
Mail servers do not fail loudly — they fail subtly. A single misrouted redirect, a missing TLS flag, or an incorrect port assignment can break the entire stack in ways that produce no obvious error message. The symptoms often look like networking problems, certificate problems, or client configuration problems, when the actual cause is one line in a configuration file.
This setup has proven reliable, debuggable, and resilient over time. The architecture is conservative by design — it makes as few assumptions as possible about how each component behaves and validates each layer independently before relying on it. If you are self-hosting email today, this architecture will save you the hours of frustration that come from discovering these failure modes one at a time.
All paths and domains shown in this post are examples. Replace them with values appropriate to your own environment before deploying.
Leave a Reply