Fixing Mixed Content Behind Caddy: Preserving HTTPS Context for Upstream Apps
The Problem: Upstream Apps Seeing HTTP Instead of HTTPS
When a client requests a resource over HTTPS, the browser expects all subsequent resources to be served over HTTPS. If your reverse proxy terminates TLS and forwards the request to an upstream application over plain HTTP, that application often assumes the original request was also HTTP. The result is that the application generates URLs, redirects, or form actions using the `http://` scheme. When the browser receives these links, it flags them as mixed content, blocking the resources and breaking the user experience.
This is not a browser bug; it is a context loss at the proxy boundary. The upstream application does not know that the original client connection was secure unless the proxy explicitly tells it. Without this information, the application defaults to the scheme of the incoming connection it sees, which is HTTP. This leads to mixed-content warnings and broken secure links in client browsers, undermining the security posture of the entire deployment.
How Caddy Handles Scheme Forwarding
Caddy uses the reverse_proxy directive to manage traffic forwarding to upstream applications. By default, Caddy does not automatically inject all the headers required for an upstream application to reconstruct the original client context. While Caddy handles the TLS termination and the TCP connection to the backend, the semantic details of the original request—specifically the scheme—must be explicitly communicated via HTTP headers.
The X-Forwarded-Proto header is the standard mechanism for communicating the original request scheme to upstream servers. It tells the backend, “The client connected using HTTPS, even though you are receiving this request over HTTP.” If this header is missing or incorrect, the upstream application cannot distinguish between a direct HTTP request and a proxied HTTPS request. This ambiguity is the root cause of scheme-related bugs in self-hosted applications.
It is critical to understand that Caddy is not magic; it is a proxy. It forwards bytes. It does not interpret the application logic of the backend. Therefore, the responsibility for ensuring the correct context is passed lies in the Caddy configuration. The configuration must ensure the original HTTPS scheme reaches the upstream application to prevent security context loss.
Configuring reverse_proxy to Pass X-Forwarded-Proto
To fix this, you must explicitly configure Caddy to add the X-Forwarded-Proto header. While some versions of Caddy may have default behaviors that include this, relying on defaults is fragile. Explicit configuration is the only way to guarantee consistent behavior across upgrades and different site blocks.
Here is the core configuration pattern. You define the site address, specify the upstream, and use the header_up directive to set the header. Note that header_up modifies the headers sent to the upstream server.
example.com {
reverse_proxy backend:8080 {
header_up X-Forwarded-Proto {scheme}
}
}
In this snippet, {scheme} is a Caddy placeholder that resolves to the scheme of the incoming request. If the client connected via HTTPS, {scheme} resolves to https. If they connected via HTTP, it resolves to http. This dynamic substitution ensures that the header always reflects the actual client connection, not a hardcoded value.
You should also consider forwarding the X-Forwarded-For and X-Forwarded-Host headers if your application relies on them for IP logging or canonical URL generation. However, X-Forwarded-Proto is the specific fix for mixed content issues. A more robust configuration often includes all three:
example.com {
reverse_proxy backend:8080 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Host {host}
}
}
This configuration is documented in the official Caddy documentation for the reverse_proxy directive, which details how header manipulation works in the proxy chain. Caddy reverse_proxy documentation provides the full reference for these placeholders and directives.
Verifying Header Propagation to Upstream Services
Configuration is only half the job. You must verify that the header is actually reaching the upstream application. A common mistake is assuming that because Caddy is configured, the backend is receiving it. In reality, the backend application might be ignoring the header, or an intermediate layer might be stripping it.
The most direct way to verify this is to inspect the logs of the upstream application. If your application logs incoming headers, look for X-Forwarded-Proto: https in the log entries for HTTPS requests. If you are running a Python web application or a WordPress instance, check their respective access logs or debug logs for this specific header.
If you do not have log access, you can use a simple test endpoint. Create a minimal upstream service that echoes back the headers it receives. For example, a simple Python script using http.server or a Node.js script can print the X-Forwarded-Proto header to the console. Point Caddy at this test service and make a request via curl:
curl -I https://example.com/test
Check the upstream console output. If you see X-Forwarded-Proto: https, the propagation is working. If you see nothing, or http, the configuration is incorrect or the backend is not reading the header.
Another verification method is to use browser developer tools. Open the Network tab, make a request, and inspect the response headers. If the application is generating absolute URLs in the response body (such as in HTML links or JSON API responses), check if they use https://. If they use http://, the header is not being respected by the application logic.
Common Pitfalls with Default Header Behavior
One of the most common pitfalls is assuming that Caddy forwards all headers by default. While Caddy does forward most headers, it does not automatically add X-Forwarded-Proto in all contexts, especially if you are using a custom reverse_proxy configuration that overrides defaults. Always check the Caddy version and the specific behavior of the reverse_proxy directive in your version.
Another pitfall is conflicting headers. If you have multiple layers of proxies (for example, a load balancer in front of Caddy, and Caddy in front of the app), you must ensure that each layer appends to the X-Forwarded-For header and sets the correct X-Forwarded-Proto. If an upstream proxy sets X-Forwarded-Proto: http and Caddy does not override it, the backend will see the incorrect scheme. Use header_up to explicitly set the value, overriding any incoming value.
Finally, be aware that some applications have their own settings for “force HTTPS” or “secure cookie” flags. These settings can conflict with the header-based approach. For example, a WordPress installation might have a constant defined to force HTTPS, which could mask the issue but also cause other problems. The goal is to have the application correctly detect the scheme via the header, not to force it via application-specific configuration. This keeps the proxy boundary clean and the application logic consistent.
Limitations: This article focuses on the Caddy configuration side. If your upstream application is not reading the X-Forwarded-Proto header, you may need to adjust the application’s configuration to respect it. This is outside the scope of Caddy administration but is a necessary follow-up step for full end-to-end HTTPS support.
Tell us what broke. What surprised you. We read every note and fold good findings back into the text.
Send a field note
LEAVE A NOTE — field-tested feedback only, please