Fixing WordPress Redirect Loops and Mixed Content in Caddy
When Caddy terminates TLS and forwards requests to an upstream WordPress instance, the application loses visibility into the original request scheme. If the forwarded scheme does not reach the application, generated asset and API URLs default to HTTP. This mismatch between the secure external connection and the insecure internal references causes browser mixed-content errors or infinite redirect loops. The fix is ensuring the X-Forwarded-Proto header accurately communicates the original HTTPS scheme to the upstream server.
Understanding the TLS Termination Boundary
Caddy acts as a reverse proxy that terminates TLS at the edge. When a client connects to https://example.com, Caddy decrypts the traffic, processes the request, and then forwards it to the backend service. By default, this backend connection is often plain HTTP unless explicitly configured otherwise. This creates a boundary where the secure context ends and the internal network context begins.
WordPress does not inherently know that the original client connection was HTTPS. It sees an incoming HTTP request from Caddy. Without explicit instruction, WordPress assumes the request is insecure. Consequently, when it generates absolute URLs for CSS, JavaScript, images, or API endpoints, it constructs them with the http:// scheme. The browser then attempts to load these resources over unencrypted HTTP, triggering mixed-content warnings or blocking the requests entirely in modern browsers.
The core issue is not a failure of Caddy’s TLS configuration, but a failure of context propagation. The reverse proxy must bridge the gap between the secure external interface and the insecure internal interface by explicitly passing the original scheme information.
The Role of X-Forwarded-Proto in Reverse Proxies
The X-Forwarded-Proto header is the standard mechanism used to communicate the original request scheme to the upstream server. It tells the backend application, “The client connected via HTTPS, even though we are talking to you over HTTP.”
According to the Caddy documentation for the reverse_proxy directive, Caddy automatically adds this header to forwarded requests. However, the upstream application must be configured to trust and utilize this header. If the application ignores the header or does not recognize it, the propagation fails, resulting in broken asset links or redirect loops. Caddy’s reverse_proxy directive handles the addition of this header by default, but the effectiveness of this mechanism depends entirely on the receiving application’s logic.
WordPress relies on the received scheme to generate correct absolute URLs for assets and APIs. It does not hardcode the scheme; it derives it from the request context. If the X-Forwarded-Proto header is missing or incorrect, WordPress falls back to the scheme of the incoming connection (HTTP), producing incorrect URLs. This is a logical dependency, not a configuration error in Caddy itself, but a misalignment in how the upstream interprets the proxy’s signals.
Diagnosing Mixed-Content and Redirect Loop Errors
Before modifying configuration, confirm that the issue is indeed scheme propagation. A redirect loop typically manifests as a browser error stating “The page redirected too many times” or a 508 Loop Detected error from the server. Mixed-content errors appear in the browser console as blocked resources or a padlock icon with a warning.
- Check the Network Tab: Open the browser developer tools, navigate to the Network tab, and inspect the request that is failing. Look at the
Request URL. If the page is loaded via HTTPS but the failing request ishttp://, the issue is mixed content caused by incorrect URL generation. - Inspect Response Headers: In the Headers tab of the failing request, check for
Locationheaders if it is a redirect. If theLocationheader points tohttp://, the application is generating insecure redirect targets. - Verify Incoming Headers: Use
curlto simulate a request and inspect what Caddy is sending to the backend. Run:
curl -I -H "Host: example.com" -H "X-Forwarded-Proto: https" http://localhost:8080
Replacelocalhost:8080with your actual upstream address. If the backend responds with a redirect tohttp://despite theX-Forwarded-Protoheader being present, the application is not honoring the header.
If the backend ignores the header, the problem lies in the WordPress configuration or the PHP environment, not in Caddy’s header injection. Caddy is doing its part; the upstream is failing to listen.
Configuring Caddy to Propagate the Correct Scheme
In most cases, Caddy’s default behavior is sufficient. The reverse_proxy directive automatically adds the X-Forwarded-Proto header based on the incoming request scheme. However, if you have custom header manipulation or are using a complex configuration, ensure you are not stripping or overriding this header.
A minimal Caddyfile configuration for WordPress looks like this:
example.com {
reverse_proxy 127.0.0.1:8080
}
Caddy will automatically add X-Forwarded-Proto: https when the incoming request is HTTPS. If you are using a local HTTP upstream, this is the correct behavior. Do not manually set X-Forwarded-Proto to a static value like http or https unless you have a specific reason to do so, as this will break the dynamic propagation.
If you are using a non-standard setup where the upstream expects a different header or if you are debugging, you can explicitly verify the header propagation. However, avoid hardcoding the header in Caddy. The dynamic nature of X-Forwarded-Proto is essential for supporting both HTTP and HTTPS clients if you ever serve both.
One common pitfall is using header_up to modify headers. Ensure you are not accidentally removing X-Forwarded-Proto when setting other headers. For example, this is incorrect:
header_up X-Forwarded-Proto https
This hardcodes the value. Instead, rely on Caddy’s automatic behavior. If you must set it, use a placeholder that reflects the incoming scheme, though this is rarely necessary with standard reverse_proxy usage.
Verifying Upstream Application Behavior
Since Caddy is correctly sending the X-Forwarded-Proto header, the fix must occur on the WordPress side. WordPress uses the PHP environment to determine the current scheme. By default, PHP does not trust X-Forwarded-Proto for security reasons. It relies on $_SERVER['HTTPS'] and $_SERVER['SERVER_PORT'].
To make WordPress aware of the proxy’s scheme, you must configure it to trust the forwarded headers. This is typically done via the wp-config.php file or a plugin that handles proxy headers. A common approach is to define constants that force WordPress to recognize the HTTPS context when behind a proxy.
Add the following to your wp-config.php file, before the /* That's all, stop editing! */ line:
if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] === 'https') {
$_SERVER['HTTPS'] = 'on';
}
This snippet checks for the X-Forwarded-Proto header. If it is present and set to https, it sets the HTTPS server variable to on. This tells PHP and WordPress that the connection is secure, causing it to generate HTTPS URLs.
Alternatively, if you are using a plugin like “Really Simple SSL” or “WP Force SSL,” ensure it is configured to detect the proxy headers. These plugins often have specific settings for “I’m behind a proxy” or “Force HTTPS” that handle this logic automatically. Verify that the plugin is active and that its settings align with Caddy’s header propagation.
If you are using a managed WordPress environment or a Docker container, check the environment variables. Some images allow you to set FORCE_SSL_ADMIN or similar variables. However, the PHP-level fix above is more universal and directly addresses the scheme detection issue.
Final Validation Checklist
After applying the Caddy configuration and the WordPress PHP adjustment, validate the fix using the following steps:
- Clear Caches: Clear any browser caches, WordPress object caches, and server-side caches (like Varnish or Redis) to ensure you are not seeing stale HTTP URLs.
- Test with curl: Run:
curl -I https://example.com
Verify that the response headers do not contain anyLocationheaders pointing tohttp://. The status code should be 200 OK or a 301/302 redirect tohttps://if applicable. - Inspect Asset URLs: Open the page in a browser, use the developer tools, and inspect the source code. All
<link>and<script>tags should usehttps://URLs. Check the Network tab to ensure no requests are being made overhttp://. - Check for Redirect Loops: Navigate to the site in a private browsing window to avoid cached cookies. Ensure the page loads without redirect errors. If you still see a loop, check the WordPress error logs for PHP warnings related to header manipulation.
- Verify Header Propagation: Use
curlto send a request with a fakeX-Forwarded-Protoheader to test the logic.
curl -I -H "X-Forwarded-Proto: http" https://example.com
If WordPress correctly detects the HTTP scheme and generates HTTP URLs (or redirects to HTTP), the logic is working. If it still forces HTTPS, the PHP snippet may be overriding the detection incorrectly. Adjust the logic to only setHTTPStoonwhen the header ishttps, and leave it unset otherwise.
Limitations: This solution assumes a standard LAMP/LEMP stack with Caddy as the reverse proxy. If you are using a non-standard PHP environment or a custom WordPress build that ignores server variables, the PHP snippet may need to be adapted. Also, if you are using multiple proxies, ensure that each proxy correctly appends to the X-Forwarded-Proto header rather than overwriting it, though Caddy’s default behavior is to set it based on the incoming request, which is correct for a single proxy layer.