Caddy Reverse Proxy for Mailu: Dual HTTPS Setup

{"time":1776378046137,"version":"2.28.2","blocks":[{"type":"header","data":{"text":"Goal","level":2}},{"type":"paragraph","data":{"text":"Set up Caddy as the public-facing reverse proxy for a Dockerized Mailu mail server, with both services sharing the same Sectigo wildcard TLS certificate. The end result:"}},{"type":"list","data":{"style":"unordered","items":["https://my.richardapplegate.io/webmail and /admin are accessible through Caddy on public port 443.","Mailu’s front container serves HTTPS internally on port 443 within the Docker network.","SMTP, IMAP, and POP3 ports (465, 993, 995, 587) use the same Sectigo wildcard certificate.","There are no port conflicts and no redirect loops."]}},{"type":"header","data":{"text":"How It Works","level":2}},{"type":"paragraph","data":{"text":"Running Caddy in front of Mailu introduces a dual-TLS architecture. Caddy terminates TLS for web traffic on public port 443 and then proxies requests to Mailu’s front container over an internal HTTPS connection. Mailu also needs its own copy of the certificate because its mail services (SMTP on port 465, IMAP on port 993, POP3 on port 995) terminate TLS directly — Caddy is not involved in mail protocol traffic."}},{"type":"paragraph","data":{"text":"The key challenge is avoiding redirect loops. When Mailu is configured with TLS_FLAVOR=cert, its front container enforces HTTPS on all connections. If Caddy proxies to Mailu over plain HTTP (http://front:80), Mailu responds with a redirect to HTTPS, which Caddy forwards to the client, which sends the request back to Caddy, creating an infinite loop. The fix is to have Caddy proxy to https://front:443 instead, so Mailu sees an HTTPS connection and serves the content directly."}},{"type":"paragraph","data":{"text":"Both Caddy and Mailu mount the same certificate files from a shared host path. This ensures that the certificate presented on port 443 (web) matches the certificate presented on ports 465, 993, and 995 (mail), which is important for clients that validate hostnames across protocols."}},{"type":"header","data":{"text":"Configuration","level":1}},{"type":"header","data":{"text":"1. Certificates on the Host","level":2}},{"type":"paragraph","data":{"text":"You need two files from your Sectigo wildcard certificate: the full chain and the private key. Store them in a stable host path that both Caddy and Mailu can mount as read-only volumes:"}},{"type":"list","data":{"style":"unordered","items":["/mnt/volumes/certs/fullchain.pem","/mnt/volumes/certs/privkey.pem"]}},{"type":"paragraph","data":{"text":"Before proceeding, verify that the certificate and private key match by comparing their modulus hashes:"}},{"type":"code","data":{"code":"openssl x509 -noout -modulus -in fullchain.pem | openssl md5\nopenssl pkey -noout -modulus -in privkey.pem | openssl md5"}},{"type":"paragraph","data":{"text":"Both commands should output the same MD5 hash. If they do not match, the certificate and key are from different pairs and TLS will fail."}},{"type":"header","data":{"text":"2. Caddy Docker Compose","level":2}},{"type":"paragraph","data":{"text":"Caddy publishes the public HTTP and HTTPS ports and mounts the certificate directory as read-only:"}},{"type":"code","data":{"code":"services:\n caddy:\n image: caddy:2\n restart: unless-stopped\n networks:\n - caddy\n ports:\n - \"80:80\"\n - \"443:443/tcp\"\n - \"443:443/udp\"\n volumes:\n - ./Caddyfile:/etc/caddy/Caddyfile:ro\n - ./data:/data\n - ./config:/config\n - /mnt/volumes/certs:/certs:ro\nnetworks:\n caddy:\n external: true"}},{"type":"paragraph","data":{"text":"The caddy network is defined as external so that other compose stacks (including Mailu) can join it."}},{"type":"header","data":{"text":"3. Mailu Environment Variables (mailu.env)","level":2}},{"type":"paragraph","data":{"text":"These are the settings that must be correct for this setup to work:"}},{"type":"code","data":{"code":"DOMAIN=richardapplegate.io\nHOSTNAMES=my,mail\n\nPORTS=25,465,587,993,995,4190\n\nTLS_FLAVOR=cert\nTLS_CERT_FILENAME=fullchain.pem\nTLS_KEYPAIR_FILENAME=privkey.pem\n\nWEB_ADMIN=/admin\nWEB_WEBMAIL=/webmail\nWEBSITE=https://my.richardapplegate.io"}},{"type":"paragraph","data":{"text":"Important notes:"}},{"type":"list","data":{"style":"unordered","items":["DOMAIN is the apex domain (richardapplegate.io), not a subdomain.","HOSTNAMES contains labels only (my,mail) — do not include dots or the full domain.","TLS_FLAVOR must be cert (not certs — this is a common mistake).","Do not include ports 80 or 443 in the PORTS list. Caddy owns those ports publicly. Including them here will cause a port conflict."]}},{"type":"header","data":{"text":"4. Mailu Docker Compose — Networking and Certificate Mounts","level":2}},{"type":"header","data":{"text":"4.1 Attach the front container to both networks","level":3}},{"type":"paragraph","data":{"text":"Mailu’s front container needs to be on two networks: the internal Mailu network (so it can communicate with the SMTP, IMAP, and other backend containers) and the external Caddy network (so Caddy can reach it for reverse proxying):"}},{"type":"code","data":{"code":"services:\n front:\n networks:\n - mailu\n - caddy"}},{"type":"header","data":{"text":"4.2 Mount the certificates into the containers that need them","level":3}},{"type":"paragraph","data":{"text":"At minimum, the front, smtp, and imap containers need the certificate files. The mount paths must match the filenames specified in TLS_CERT_FILENAME and TLS_KEYPAIR_FILENAME:"}},{"type":"code","data":{"code":"services:\n front:\n volumes:\n - /mnt/volumes/certs/fullchain.pem:/certs/fullchain.pem:ro\n - /mnt/volumes/certs/privkey.pem:/certs/privkey.pem:ro\n\n smtp:\n volumes:\n - /mnt/volumes/certs/fullchain.pem:/certs/fullchain.pem:ro\n - /mnt/volumes/certs/privkey.pem:/certs/privkey.pem:ro\n\n imap:\n volumes:\n - /mnt/volumes/certs/fullchain.pem:/certs/fullchain.pem:ro\n - /mnt/volumes/certs/privkey.pem:/certs/privkey.pem:ro"}},{"type":"header","data":{"text":"5. Caddyfile","level":2}},{"type":"paragraph","data":{"text":"Caddy terminates TLS for incoming web traffic and proxies to Mailu’s front container over internal HTTPS. This is the critical part — proxying to https://front:443 instead of http://front:80 is what prevents redirect loops:"}},{"type":"code","data":{"code":"my.richardapplegate.io {\n tls /certs/fullchain.pem /certs/privkey.pem\n\n # Optional: redirect the root path to webmail\n @root path /\n redir @root /webmail 302\n\n reverse_proxy https://front:443 {\n transport http {\n tls_server_name my.richardapplegate.io\n # Uncomment only if you encounter certificate chain issues:\n # tls_insecure_skip_verify\n }\n\n header_up Host {host}\n header_up X-Forwarded-Proto {scheme}\n header_up X-Forwarded-Host {host}\n header_up X-Forwarded-For {remote_host}\n }\n}"}},{"type":"paragraph","data":{"text":"Why proxy to HTTPS upstream? Because TLS_FLAVOR=cert tells Mailu’s front container to enforce HTTPS on all connections. If Caddy connects over plain HTTP, Mailu responds with a 301 redirect to HTTPS. The client follows the redirect back to Caddy, Caddy proxies over HTTP again, and the cycle repeats indefinitely. Connecting to https://front:443 avoids this entirely."}},{"type":"header","data":{"text":"6. Restart Order","level":2}},{"type":"paragraph","data":{"text":"After making configuration changes, restart the services in this order. Mailu must come up first so that its front container is available when Caddy tries to proxy to it."}},{"type":"header","data":{"text":"6.1 Restart Mailu","level":3}},{"type":"paragraph","data":{"text":"Use --force-recreate to ensure that environment variable and volume mount changes take effect:"}},{"type":"code","data":{"code":"cd /mnt/volumes/SamsungSSD970EVOPlus2TB/mailu\ndocker compose down\ndocker compose up -d --force-recreate"}},{"type":"header","data":{"text":"6.2 Reload Caddy","level":3}},{"type":"paragraph","data":{"text":"Reload the Caddyfile without restarting the container:"}},{"type":"code","data":{"code":"docker exec caddy caddy reload --config /etc/caddy/Caddyfile"}},{"type":"header","data":{"text":"7. Verification Tests","level":2}},{"type":"paragraph","data":{"text":"Run the following commands to confirm that every service is presenting the correct Sectigo wildcard certificate. All four tests should show CN=*.richardapplegate.io with a Sectigo issuer."}},{"type":"header","data":{"text":"7.1 Web certificate (public port 443 via Caddy)","level":3}},{"type":"code","data":{"code":"openssl s_client -connect my.richardapplegate.io:443 -servername my.richardapplegate.io /dev/null \\\n| openssl x509 -noout -subject -issuer -dates"}},{"type":"header","data":{"text":"7.2 Mailu front certificate (internal, from inside the Caddy container)","level":3}},{"type":"code","data":{"code":"docker exec caddy sh -lc \\\n'openssl s_client -connect front:443 -servername my.richardapplegate.io /dev/null | openssl x509 -noout -subject -issuer -dates'"}},{"type":"header","data":{"text":"7.3 SMTP TLS certificate (port 465)","level":3}},{"type":"code","data":{"code":"openssl s_client -connect my.richardapplegate.io:465 -servername my.richardapplegate.io /dev/null \\\n| openssl x509 -noout -subject -issuer -dates"}},{"type":"header","data":{"text":"7.4 IMAP TLS certificate (port 993)","level":3}},{"type":"code","data":{"code":"openssl s_client -connect my.richardapplegate.io:993 -servername my.richardapplegate.io /dev/null \\\n| openssl x509 -noout -subject -issuer -dates"}},{"type":"paragraph","data":{"text":"All four commands should return the same certificate with CN=*.richardapplegate.io and a Sectigo issuer. If any test shows a different certificate or a self-signed certificate, check that the correct files are mounted into the corresponding container."}}]}