Caddy Reverse Proxy: Troubleshooting TLS Handshake Failed Verification on macOS Local
Resolve Caddy's 'TLS handshake failed verification' error on macOS local setups. Covers untrusted certificates, hostname issues, and configuration fixes.
Resolve Caddy's 'TLS handshake failed verification' error on macOS local setups. Covers untrusted certificates, hostname issues, and configuration fixes.
Introduction
As a Systems Administrator, encountering TLS/SSL errors in local development environments is a common challenge. When Caddy, acting as a reverse proxy, reports a "TLS handshake failed verification" error on your macOS machine, it typically means Caddy cannot establish a trusted secure connection to your upstream (backend) server. This guide will walk you through the diagnostic steps and provide robust solutions tailored for a macOS local environment.
Symptom & Error Signature
Users will typically observe a 502 Bad Gateway error in their browser when trying to access the Caddy-proxied service, or a connection refused/timeout message. The most indicative information, however, comes from Caddy's logs.
Here's what a typical error signature looks like in Caddy's output, often found in stderr if running directly, or in journalctl if running as a service, or Docker logs:
ERROR reverse_proxy handshake_error error="remote error: tls: handshake failure" request={"proto":"HTTP/2.0","remote_addr":"127.0.0.1:56819","server_name":"localhost","host":"mylocalservice.test","method":"GET","uri":"/"}
ERROR http.log.error reverse proxying for http://mylocalservice.test failed: remote error: tls: handshake failure
ERROR http.log.access 127.0.0.1:56819 - "GET / HTTP/2.0" 502 {"error":"reverse proxying for http://mylocalservice.test failed: remote error: tls: handshake failure"}
Or a more explicit verification error:
ERROR reverse_proxy handshake_error error="x509: certificate signed by unknown authority" request={"proto":"HTTP/2.0","remote_addr":"127.0.0.1:56819","server_name":"localhost","host":"mylocalservice.test","method":"GET","uri":"/"}
Root Cause Analysis
The "TLS handshake failed verification" error means that when Caddy tried to initiate a secure (HTTPS) connection to your backend server, the backend presented a TLS certificate that Caddy, acting as a client, could not validate. This typically stems from one of the following reasons in a local macOS environment:
- Self-Signed Certificate: The backend server is using a self-signed TLS certificate. By default, Caddy (like most TLS clients) does not trust self-signed certificates because they lack a chain of trust back to a publicly recognized Certificate Authority (CA).
- Custom/Development CA: The backend server's certificate was issued by a custom Certificate Authority (e.g., one generated by
mkcert,minica, or similar tools for local development). Whilemkcertdoes a good job of adding its root CA to your system's trust store on macOS, Caddy might not automatically pick up these system-wide additions if it's running in an isolated environment (like Docker) or if its own trust store isn't correctly configured. - Hostname Mismatch: The hostname Caddy is attempting to connect to (e.g.,
my-backend-app:8443) does not match any of the Subject Alternative Names (SANs) or the Common Name (CN) specified in the backend server's TLS certificate. - Expired or Invalid Certificate: The backend server's TLS certificate has expired, is not yet valid, or has been revoked.
- Intermediate Certificate Chain Incompleteness: The backend server is not sending its full certificate chain, leading Caddy to fail building a trusted path to a root CA.
- Firewall/Proxy Interference: Less common for a "local environment" but a system-wide proxy or firewall could be intercepting TLS traffic, presenting its own untrusted certificate.
On macOS, specifically, the interaction between system-wide trust stores (managed by Keychain Access) and individual application trust stores can sometimes lead to confusion. Caddy, being a Go application, typically relies on the Go runtime's TLS mechanisms, which usually defer to the system's root CAs, but can be explicitly configured.
Step-by-Step Resolution
Follow these steps to diagnose and resolve the TLS handshake verification error.
1. Inspect the Backend Server's TLS Certificate
First, verify the details of the certificate presented by your backend server. This will help identify issues like self-signed status, expiration, or hostname mismatches.
# Replace 'your-backend-host.local' and '8443' with your actual backend's hostname and port
openssl s_client -connect your-backend-host.local:8443 -servername your-backend-host.local </dev/null 2>/dev/null | openssl x509 -noout -text
Examine the output for:
Issuer:: If it's the same asSubject:, it's a self-signed certificate. If it's a custom CA, note its name.Validity: CheckNot BeforeandNot Afterdates.Subject:andX509v3 Subject Alternative Name:: Ensure the hostname Caddy is trying to reach is listed here.
2. Ensure Custom CA is Trusted on macOS (If Applicable)
If your backend uses a certificate signed by a custom CA (like mkcert), ensure that CA's root certificate is trusted by your macOS system. mkcert automates this, but manual verification is useful.
# List mkcert's installed root CAs (if mkcert is used)
mkcert -CAROOT
ls "$(mkcert -CAROOT)"
You can also check Keychain Access.app (under "System" or "login" keychains) for your custom root CA and ensure it's marked as "Always Trust".
While
mkcertinstalls the CA into the macOS system trust store, Caddy running in a Docker container or a specific Go environment might not automatically inherit this. This step ensures the system trusts the CA, which is a prerequisite for some Caddy configurations to work correctly.
3. Configure Caddy to Trust the Backend Certificate
This is the most critical step. You have several options, ranging from most secure to least secure, depending on your environment.
Option A: Trust a Specific Custom CA Certificate (Recommended for Local Dev)
If your backend certificate is signed by a custom CA (e.g., mkcert's root CA), explicitly tell Caddy to trust that CA. This is secure because Caddy will only trust certificates signed by your specified CA.
Locate your CA certificate: If using
mkcert, it's typically in$(mkcert -CAROOT)/rootCA.pem.mkcert -CAROOT # This will output the path to your CA root ROOT_CA_PATH=$(mkcert -CAROOT)/rootCA.pem echo "Your CA certificate is at: $ROOT_CA_PATH"Add
trusted_ca_certsto your Caddyfile: Modify yourCaddyfilefor thereverse_proxydirective.mylocalservice.test { reverse_proxy https://your-backend-host.local:8443 { transport http { tls_trusted_ca_certs /path/to/your/rootCA.pem # Example for mkcert: tls_trusted_ca_certs /Users/youruser/Library/Application Support/mkcert/rootCA.pem } } }Replace
/path/to/your/rootCA.pemwith the actual path obtained above. If Caddy is in Docker, you'll need to mount this file into the container.
Option B: Insecurely Skip TLS Verification (Use with Extreme Caution)
This option disables certificate verification entirely. Only use this for strictly local, non-sensitive development environments where you fully understand the risks. Never use this in production.
mylocalservice.test {
reverse_proxy https://your-backend-host.local:8443 {
transport http {
tls_insecure_skip_verify
}
}
}
tls_insecure_skip_verifybypasses all TLS certificate validation. This leaves your connection vulnerable to Man-in-the-Middle (MITM) attacks. Only use this for temporary debugging or highly controlled local development where security is not a concern.
4. Verify Hostname Match
Ensure the hostname Caddy is connecting to in the reverse_proxy directive exactly matches a Subject Alternative Name (SAN) or Common Name (CN) in the backend server's certificate.
If your backend's certificate is issued for backend.local, but you're proxying to 127.0.0.1:8443, Caddy will see a mismatch. You might need to specify the hostname within the transport http block.
mylocalservice.test {
reverse_proxy https://127.0.0.1:8443 {
transport http {
# This tells Caddy to present 'backend.local' as the SNI hostname,
# and to expect a certificate for 'backend.local'
tls_servername backend.local
tls_trusted_ca_certs /path/to/your/rootCA.pem # Or tls_insecure_skip_verify
}
}
}
In this example, backend.local is the hostname that the upstream server's certificate is issued for.
5. Check Certificate Expiry and Validity
If openssl s_client output showed an expired certificate:
- Generate a new certificate for your backend server.
- If using
mkcert, ensure your local CA is still valid, and then re-generate the backend certificate usingmkcert.
6. Reload/Restart Caddy
After making changes to your Caddyfile, you need to apply them.
For Caddy running directly (e.g., caddy run) or via brew services:
If you're running Caddy directly, you might need to stop and restart it. If caddy reload doesn't work, a full restart is usually necessary for TLS trust changes.
# If Caddy is running in the foreground, Ctrl+C to stop, then:
caddy run -config Caddyfile -adapter caddyfile
# If using brew services on macOS:
brew services restart caddy
For Caddy running as a Systemd service (e.g., on a Linux VM/Docker Desktop VM):
sudo systemctl reload caddy
# If reload doesn't work or for more significant changes:
sudo systemctl restart caddy
sudo systemctl status caddy
For Caddy running in Docker:
docker compose restart caddy-service # If using docker compose
# Or if running a single container:
docker restart <caddy-container-name-or-id>
docker logs <caddy-container-name-or-id>
When running Caddy in Docker and using
tls_trusted_ca_certs, ensure that the/path/to/your/rootCA.pemfile is properly mounted into the Caddy container at the specified path. For example, indocker-compose.yml:services: caddy: image: caddy:latest ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile - ./caddy_data:/data - /Users/youruser/Library/Application Support/mkcert/rootCA.pem:/etc/caddy/rootCA.pem:ro # Mount your CA # Ensure Caddy user can read the mounted CA file user: root # Or ensure file permissions are correct for caddy userAdjust the host path for
rootCA.pemto match your system.
By systematically working through these steps, you should be able to identify and resolve the "TLS handshake failed verification" error with Caddy acting as a reverse proxy on your macOS local development environment.