{"id":12,"date":"2026-06-27T00:00:00","date_gmt":"2026-06-27T00:00:00","guid":{"rendered":"http:\/\/\/var\/www\/html\/?p=12"},"modified":"2026-07-21T01:02:30","modified_gmt":"2026-07-21T01:02:30","slug":"caddy-reverse-proxy-tls-certificate-verification-fails-handshake-errors","status":"publish","type":"post","link":"https:\/\/butitworkedlocal.com\/caddy-reverse-proxy-tls-certificate-verification-fails-handshake-errors\/","title":{"rendered":"Caddy Reverse Proxy: Troubleshooting TLS Certificate Verification Failures &amp; Handshake Errors"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\"><strong>Resolve Caddy reverse proxy TLS certificate verification and handshake errors when connecting to upstream servers, covering common causes like self-signed certificates and untrusted CAs.<\/strong><\/p>\n\n\n<p>Caddy is an incredibly powerful and user-friendly web server and reverse proxy, known for its automatic HTTPS capabilities. However, when configuring Caddy to act as a reverse proxy to an upstream backend server that also uses HTTPS (e.g., an internal API, another web server, or a microservice), you might encounter TLS certificate verification failures or handshake errors. These issues prevent Caddy from establishing a secure connection to your backend, resulting in <code>502 Bad Gateway<\/code> errors for your users or a broken application.<\/p>\n<p>This guide will walk you through diagnosing and resolving these common TLS issues, focusing on scenarios where Caddy, as a client, cannot trust the backend server&#039;s presented certificate.<\/p>\n<h3>Symptom &amp; Error Signature<\/h3>\n<p>When Caddy fails to verify the TLS certificate of an upstream backend, end-users will typically see a <code>502 Bad Gateway<\/code> error in their browser. In your Caddy logs, accessible via <code>journalctl -u caddy<\/code> (for systemd installations) or <code>docker logs &lt;caddy-container-name&gt;<\/code> (for Docker deployments), you&#039;ll find entries similar to these:<\/p>\n<pre><code class=\"language-json\">{&quot;level&quot;:&quot;error&quot;,&quot;ts&quot;:1678886400.000000,&quot;logger&quot;:&quot;http.log.error&quot;,&quot;msg&quot;:&quot;x509: certificate signed by unknown authority&quot;,&quot;request&quot;:{&quot;remote_ip&quot;:&quot;192.168.1.1&quot;,&quot;remote_port&quot;:&quot;54321&quot;,&quot;proto&quot;:&quot;HTTP\/2.0&quot;,&quot;method&quot;:&quot;GET&quot;,&quot;host&quot;:&quot;your-domain.com&quot;,&quot;uri&quot;:&quot;\/app\/&quot;,&quot;headers&quot;:{&quot;User-Agent&quot;:[&quot;Mozilla\/5.0...&quot;],&quot;Accept&quot;:[&quot;text\/html,...&quot;]}},&quot;duration&quot;:0.000000,&quot;status&quot;:502,&quot;err_id&quot;:&quot;some-uuid&quot;,&quot;err_trace&quot;:&quot;reverseproxy.go:881&quot;}\n{&quot;level&quot;:&quot;error&quot;,&quot;ts&quot;:1678886400.000000,&quot;logger&quot;:&quot;http.reverseproxy&quot;,&quot;msg&quot;:&quot;handshake error: x509: certificate signed by unknown authority&quot;,&quot;error&quot;:&quot;x509: certificate signed by unknown authority&quot;}\n{&quot;level&quot;:&quot;error&quot;,&quot;ts&quot;:1678886400.000000,&quot;logger&quot;:&quot;http.reverseproxy&quot;,&quot;msg&quot;:&quot;tls: failed to verify certificate: x509: certificate is not valid yet&quot;,&quot;error&quot;:&quot;tls: failed to verify certificate: x509: certificate is not valid yet&quot;}\n{&quot;level&quot;:&quot;error&quot;,&quot;ts&quot;:1678886400.000000,&quot;logger&quot;:&quot;http.reverseproxy&quot;,&quot;msg&quot;:&quot;tls: failed to verify certificate: x509: cannot validate certificate for 10.0.0.5 because it doesn&#039;t contain any IP SANs&quot;,&quot;error&quot;:&quot;tls: failed to verify certificate: x509: cannot validate certificate for 10.0.0.5 because it doesn&#039;t contain any IP SANs&quot;}\n<\/code><\/pre>\n<p>Common error messages include:<\/p>\n<ul>\n<li><code>x509: certificate signed by unknown authority<\/code><\/li>\n<li><code>tls: failed to verify certificate<\/code><\/li>\n<li><code>x509: certificate is not valid yet<\/code> or <code>x509: certificate has expired<\/code><\/li>\n<li><code>x509: cannot validate certificate for &lt;hostname\/IP&gt; because it doesn&#039;t contain any IP SANs<\/code> (Subject Alternative Names)<\/li>\n<\/ul>\n<h3>Root Cause Analysis<\/h3>\n<p>The &quot;certificate verification fails&quot; error means Caddy, acting as a TLS client, received a certificate from the backend server but could not trust it based on its configured trust stores and policies. Here are the most common underlying reasons:<\/p>\n<ol>\n<li><p><strong>Untrusted Certificate Authority (CA)<\/strong>: This is the most frequent cause.<\/p>\n<ul>\n<li>The backend server uses a <strong>self-signed certificate<\/strong>.<\/li>\n<li>The backend&#039;s certificate is issued by a <strong>private or internal CA<\/strong> whose root certificate is not trusted by Caddy (or the underlying operating system).<\/li>\n<li>The certificate is issued by a publicly trusted CA, but its <strong>intermediate certificates are not sent<\/strong> by the backend, preventing Caddy from building a complete trust chain.<\/li>\n<\/ul>\n<\/li>\n<li><p><strong>Expired or Not Yet Valid Certificate<\/strong>: The backend server&#039;s certificate is outside its designated validity period. Caddy will correctly reject it.<\/p>\n<\/li>\n<li><p><strong>Hostname Mismatch<\/strong>: The hostname or IP address Caddy uses to connect to the backend (e.g., <code>https:\/\/backend-service.internal<\/code>) does not match any of the Subject Alternative Names (SANs) or the Common Name (CN) specified in the backend&#039;s certificate.<\/p>\n<\/li>\n<li><p><strong>Time Skew<\/strong>: The system clock on the Caddy server is significantly out of sync with the actual time, leading to incorrect validation of certificate validity periods.<\/p>\n<\/li>\n<li><p><strong>Backend Misconfiguration<\/strong>: The backend server itself might be configured to present an incorrect certificate, or its TLS setup is otherwise flawed.<\/p>\n<\/li>\n<\/ol>\n<h3>Step-by-Step Resolution<\/h3>\n<p>Follow these steps to diagnose and resolve Caddy&#039;s TLS certificate verification issues with your backend.<\/p>\n<h4>1. Verify Backend Certificate Status<\/h4>\n<p>Before modifying Caddy, inspect the backend server&#039;s certificate. This will reveal if the certificate itself is the problem.<\/p>\n<blockquote><p>Replace <code>backend.example.com<\/code> with your backend&#039;s hostname or IP, and <code>8443<\/code> with its HTTPS port. If your backend uses a hostname for its certificate but you&#039;re connecting via IP, specify the hostname with <code>-servername<\/code>.<\/p>\n<\/blockquote>\n<pre><code class=\"language-bash\"># Connect to the backend and inspect its certificate\nopenssl s_client -connect backend.example.com:8443 -showcerts -servername backend.example.com\n<\/code><\/pre>\n<p>Carefully examine the output:<\/p>\n<ul>\n<li><strong><code>Verify return code: 0 (ok)<\/code><\/strong>: Indicates the certificate <em>is<\/em> trusted by your current system&#039;s OpenSSL (which might differ from Caddy&#039;s trust store depending on installation). If it&#039;s not <code>0 (ok)<\/code>, you have a clear indication of a trust issue.<\/li>\n<li><strong><code>subject=<\/code> and <code>X509v3 Subject Alternative Name:<\/code><\/strong>: Check if the hostname\/IP you&#039;re connecting to is listed here.<\/li>\n<li><strong><code>Not Before:<\/code> and <code>Not After:<\/code><\/strong>: Ensure the current date falls within this validity window.<\/li>\n<li><strong><code>Issuer:<\/code><\/strong>: Note the issuer. If it&#039;s your own domain, it&#039;s likely self-signed or from an internal CA.<\/li>\n<li>The presented certificate chain (multiple <code>---BEGIN CERTIFICATE---<\/code> blocks).<\/li>\n<\/ul>\n<h4>2. Install\/Trust Custom CA or Self-Signed Certificate<\/h4>\n<p>If <code>openssl s_client<\/code> shows <code>Verify return code: 21 (unable to verify the first certificate)<\/code> or <code>18 (self signed certificate)<\/code>, you need to make Caddy trust the backend&#039;s certificate or its issuing CA.<\/p>\n<p><strong>Method A: System-wide Trust (Recommended for internal CA roots)<\/strong><\/p>\n<p>If your backend&#039;s certificate is signed by an internal Certificate Authority, add that CA&#039;s root certificate to the operating system&#039;s trust store. Caddy, when installed via systemd, typically respects this.<\/p>\n<ol>\n<li><p><strong>Obtain the CA Root Certificate<\/strong>:<\/p>\n<ul>\n<li>If you have the <code>.crt<\/code> file for your internal CA&#039;s root certificate (e.g., <code>my-internal-ca-root.crt<\/code>), copy it to the Caddy server.<\/li>\n<li>If your backend is self-signed, you can extract its <em>server certificate<\/em> (the first one in <code>openssl s_client -showcerts<\/code> output) and use it as the &quot;CA&quot; for itself. Save the first <code>---BEGIN CERTIFICATE---<\/code> to <code>backend-self-signed.crt<\/code>.<\/li>\n<\/ul>\n<\/li>\n<li><p><strong>Add to System Trust Store<\/strong>:<\/p>\n<pre><code class=\"language-bash\">sudo cp \/path\/to\/my-internal-ca-root.crt \/usr\/local\/share\/ca-certificates\/\nsudo update-ca-certificates\n<\/code><\/pre>\n<\/li>\n<li><p><strong>Restart Caddy<\/strong>:<\/p>\n<pre><code class=\"language-bash\">sudo systemctl reload caddy\n# If issues persist, try a full restart:\n# sudo systemctl restart caddy\n<\/code><\/pre>\n<\/li>\n<\/ol>\n<p><strong>Method B: Caddy-specific Trust (For specific upstream certificates\/CA)<\/strong><\/p>\n<p>Caddy allows you to specify trusted CA certificates directly within your Caddyfile for specific upstream reverse proxies. This is often cleaner for isolated scenarios or when you don&#039;t want system-wide changes.<\/p>\n<ol>\n<li><p><strong>Obtain the CA Root Certificate<\/strong>: Same as Method A (or the self-signed server cert). Place it in a location accessible by Caddy (e.g., <code>\/etc\/caddy\/certs\/my-internal-ca-root.crt<\/code>).<\/p>\n<\/li>\n<li><p><strong>Modify Caddyfile<\/strong>:<\/p>\n<pre><code class=\"language-caddyfile\">your-domain.com {\n    reverse_proxy https:\/\/backend.example.com:8443 {\n        transport http {\n            tls_trusted_ca_certs \/etc\/caddy\/certs\/my-internal-ca-root.crt\n        }\n    }\n}\n<\/code><\/pre>\n<\/li>\n<li><p><strong>Apply Caddyfile Changes<\/strong>:<\/p>\n<pre><code class=\"language-bash\">sudo caddy fmt --overwrite \/etc\/caddy\/Caddyfile # Format your Caddyfile\nsudo systemctl reload caddy\n<\/code><\/pre>\n<\/li>\n<\/ol>\n<h4>3. Handle Hostname Mismatch (SAN\/CN Issues)<\/h4>\n<p>If the backend&#039;s certificate doesn&#039;t list the hostname or IP Caddy uses to connect, you have a hostname mismatch.<\/p>\n<ol>\n<li><p><strong>Option A: Correct Caddy&#039;s Backend Address<\/strong>:\nThe simplest fix is to ensure the hostname Caddy uses in <code>reverse_proxy<\/code> matches a SAN or CN in the backend&#039;s certificate.<\/p>\n<pre><code class=\"language-caddyfile\">your-domain.com {\n    # If backend.example.com is in the backend&#039;s certificate SANs\n    reverse_proxy https:\/\/backend.example.com:8443\n}\n<\/code><\/pre>\n<p>If you&#039;re connecting to an IP (e.g., <code>10.0.0.5<\/code>) but the certificate is for a hostname (e.g., <code>api.internal<\/code>), you must connect via the hostname. If the certificate <em>only<\/em> has IP SANs and you&#039;re connecting via hostname, connect via IP.<\/p>\n<\/li>\n<li><p><strong>Option B: Specify <code>tls_server_name<\/code><\/strong>:\nIf you must connect to a specific IP or hostname (e.g., <code>10.0.0.5<\/code>) but the backend&#039;s certificate is issued for a different hostname (e.g., <code>api.internal<\/code>), you can tell Caddy which <code>Host<\/code> header to send during the TLS handshake for SNI and certificate validation.<\/p>\n<pre><code class=\"language-caddyfile\">your-domain.com {\n    reverse_proxy https:\/\/10.0.0.5:8443 {\n        transport http {\n            tls_server_name api.internal\n        }\n    }\n}\n<\/code><\/pre>\n<blockquote class=\"important\"><p><code>tls_server_name<\/code> affects the <code>Host<\/code> header sent during the TLS handshake (SNI) for certificate validation. It does <em>not<\/em> change the <code>Host<\/code> header sent in the HTTP request itself. If your backend relies on the HTTP <code>Host<\/code> header, you might also need <code>header_up Host api.internal<\/code> in the <code>reverse_proxy<\/code> block.<\/p>\n<\/blockquote>\n<\/li>\n<\/ol>\n<h4>4. Address Expired or Not Yet Valid Certificates<\/h4>\n<p>If <code>openssl s_client<\/code> showed <code>Not Before<\/code> \/ <code>Not After<\/code> issues, the backend&#039;s certificate is invalid by date.<\/p>\n<ol>\n<li><p><strong>Action<\/strong>: The only proper solution is to <strong>renew the backend server&#039;s certificate<\/strong>. This is a configuration task on the backend server itself.<\/p>\n<\/li>\n<li><p><strong>Temporary Workaround (USE WITH EXTREME CAUTION)<\/strong>:\nFor development, testing, or truly isolated internal networks where security is less critical, you can instruct Caddy to <em>skip<\/em> TLS certificate verification.<\/p>\n<blockquote class=\"warning\"><p><strong>Do not use <code>tls_insecure_skip_verify<\/code> in production environments<\/strong> unless you fully understand the security implications. It disables crucial security checks, making your connection vulnerable to man-in-the-middle attacks.<\/p>\n<\/blockquote>\n<pre><code class=\"language-caddyfile\">your-domain.com {\n    reverse_proxy https:\/\/backend.example.com:8443 {\n        transport http {\n            tls_insecure_skip_verify\n        }\n    }\n}\n<\/code><\/pre>\n<p>After modifying, <code>sudo systemctl reload caddy<\/code>.<\/p>\n<\/li>\n<\/ol>\n<h4>5. Ensure Full Certificate Chain is Sent by Backend<\/h4>\n<p>If <code>openssl s_client<\/code> shows missing intermediate certificates (only the leaf certificate is returned, and <code>Verify return code<\/code> is not <code>0 (ok)<\/code> but indicates a chain issue), the backend server is misconfigured.<\/p>\n<ol>\n<li><strong>Action<\/strong>: Configure the backend server to send its full certificate chain, including any intermediate CA certificates, along with its server certificate.<ul>\n<li><strong>For Nginx backend<\/strong>: Ensure your <code>ssl_certificate<\/code> directive points to a file containing both your server certificate <em>and<\/em> all intermediate certificates (usually a <code>fullchain.pem<\/code> file).<pre><code class=\"language-nginx\"># Example Nginx backend config\nserver {\n    listen 443 ssl;\n    server_name api.internal;\n\n    ssl_certificate \/etc\/nginx\/ssl\/fullchain.pem; # Contains server cert + intermediates\n    ssl_certificate_key \/etc\/nginx\/ssl\/private.key;\n    # ...\n}\n<\/code><\/pre>\n<\/li>\n<li>Consult your backend server&#039;s documentation for correct certificate chain configuration.<\/li>\n<\/ul>\n<\/li>\n<\/ol>\n<h4>6. Synchronize System Time<\/h4>\n<p>If time skew is suspected as the cause for <code>certificate is not valid yet<\/code> or <code>has expired<\/code> messages, ensure your Caddy server&#039;s time is accurate.<\/p>\n<ol>\n<li><strong>Install\/Verify NTP\/Chrony<\/strong>: Most modern Linux distributions use <code>systemd-timesyncd<\/code> by default or you can install <code>ntp<\/code> or <code>chrony<\/code>.<pre><code class=\"language-bash\"># For systemd-timesyncd (common on Ubuntu 20.04+)\ntimedatectl status\n\n# For NTP\nsudo apt update &amp;&amp; sudo apt install ntp\nsudo systemctl status ntp\nntpq -p # Check NTP peer synchronization\n\n# For Chrony\nsudo apt update &amp;&amp; sudo apt install chrony\nsudo systemctl status chrony\nchronyc sources -v # Check Chrony sources\n<\/code><\/pre>\n<\/li>\n<li>Restart Caddy after ensuring time synchronization: <code>sudo systemctl restart caddy<\/code>.<\/li>\n<\/ol>\n<h4>7. Debugging with Caddy Logs<\/h4>\n<p>If after all these steps, you are still experiencing issues, increase Caddy&#039;s log verbosity to <code>DEBUG<\/code> to get more insight into the TLS handshake process.<\/p>\n<ol>\n<li><strong>Modify Caddyfile Global Configuration<\/strong>:<pre><code class=\"language-caddyfile\">{\n    # Other global options...\n    log {\n        output stderr\n        level DEBUG\n    }\n}\n\nyour-domain.com {\n    reverse_proxy https:\/\/backend.example.com:8443 {\n        # ...\n    }\n}\n<\/code><\/pre>\n<\/li>\n<li><strong>Apply Changes and Monitor Logs<\/strong>:<pre><code class=\"language-bash\">sudo caddy fmt --overwrite \/etc\/caddy\/Caddyfile\nsudo systemctl reload caddy\njournalctl -u caddy -f # Monitor logs in real-time\n<\/code><\/pre>\nLook for more detailed TLS-related messages during the handshake attempt.<\/li>\n<\/ol>\n<p>By systematically working through these troubleshooting steps, you should be able to identify and resolve the root cause of your Caddy reverse proxy TLS certificate verification failures.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Resolve Caddy reverse proxy TLS certificate verification and handshake errors when connecting to upstream servers, covering common causes like self-signed certificates and untrusted CAs.<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[2],"tags":[36,38,40,39,37,27,34,28],"class_list":["post-12","post","type-post","status-publish","format-standard","hentry","category-web-server","tag-caddy","tag-certificate-verification","tag-devops","tag-handshake-error","tag-reverse-proxy","tag-ssl","tag-sysadmin","tag-tls"],"_links":{"self":[{"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/posts\/12","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/comments?post=12"}],"version-history":[{"count":2,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/posts\/12\/revisions"}],"predecessor-version":[{"id":260,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/posts\/12\/revisions\/260"}],"wp:attachment":[{"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/media?parent=12"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/categories?post=12"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/butitworkedlocal.com\/wp-json\/wp\/v2\/tags?post=12"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}