Mixed content behind a proxy: why your app builds http:// links, and the fix per framework.
A proxy that terminates HTTPS talks to your app over plain HTTP, so the app builds http:// URLs for assets, redirects and callbacks. The browser then blocks scripts and stylesheets as mixed content. The fix is one setting that tells the framework to trust the proxy’s X-Forwarded-Proto header, and only from the proxy’s address.
Facts checked
Why an HTTPS site ends up with http:// links
The visitor’s browser connects to the proxy over HTTPS. The proxy decrypts the request and forwards it to your app on a local port, over HTTP. From the app’s side, nothing says the original request was secure. A framework that builds absolute URLs from the incoming request (asset helpers, url() and redirect() calls, OAuth callback URLs, sitemap links) writes http://.
Proxies pass the original details in headers. Your framework ignores them until you tell it which proxy to believe, because any client can send an X-Forwarded-Proto header of its own.
What the browser does with mixed content
Browsers split http:// resources on an HTTPS page into two groups:
- A redirect loop (
ERR_TOO_MANY_REDIRECTS) is the same bug from the other side: the app forces HTTPS, sees HTTP on every request and redirects again. - Logins that don’t stick: a session cookie marked
secureis never set when the framework believes the connection is HTTP. - Every visitor has the same IP, the proxy’s, in your logs and rate limits. That’s
X-Forwarded-Forbeing ignored.
| Resource | What the browser does | What you see |
|---|---|---|
<img src>, <audio>, <video>, CSS background-image † | Upgrades the request to https:// automatically (unless the host is an IP address) | Usually nothing; a console warning |
<script src>, <link rel=stylesheet>, <iframe>, fetch(), XHR, web fonts, srcset images † | Blocks the request | A page with no styles, dead buttons, a broken embed. The console names the blocked http:// URL |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `<img src>`, `<audio>`, `<video>`, CSS `background-image` (2026-10-29) · `<script src>`, `<link rel=stylesheet>`, `<iframe>`, `fetch()`, XHR, web fonts, `srcset` images (2026-10-29)
The headers Canner’s proxy sends
On Canner, Caddy terminates HTTPS and forwards to your app on 127.0.0.1. These are the headers your app receives. Caddy ignores X-Forwarded-* values sent by clients, so the values are its own:
| Header | Value | Notes |
|---|---|---|
Host † | The hostname the visitor typed | Passed through unchanged: your-project.canner.app or your custom domain |
X-Forwarded-Proto † | https | Set by Caddy. Plain HTTP requests are redirected to HTTPS before they reach the app |
X-Forwarded-Host † | Same as Host | Caddy doesn’t rewrite Host, so you rarely need this one |
X-Forwarded-For † | The visitor’s IP | One entry. Canner’s optional cache passes it through unchanged |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. `Host` (2026-10-29) · `X-Forwarded-Proto` (2026-10-29) · `X-Forwarded-Host` (2026-10-29) · `X-Forwarded-For` (2026-10-29)
The setting for each framework
Trust the proxy’s address, not everyone. On Canner that address is loopback, because your app listens on 127.0.0.1. The last column says what Canner’s default start command already handles.
// Express
app.set("trust proxy", "loopback");
# Django (settings.py)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Flask
from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Rails (config/environments/production.rb, Rails 8 default)
config.assume_ssl = true
config.force_ssl = true
// Laravel (bootstrap/app.php)
->withMiddleware(function (Middleware $middleware): void {
$middleware->trustProxies(at: '127.0.0.1');
})
# Spring Boot (application.properties)
server.forward-headers-strategy=native| Framework | Without the setting | The setting | On Canner |
|---|---|---|---|
| Express † † | req.protocol is http, req.secure is false, req.ip is the proxy; express-session won’t set a secure cookie | app.set('trust proxy', 'loopback') | Needed: Express trusts no proxy by default |
| Django † † | request.is_secure() false: CSRF 403s, SECURE_SSL_REDIRECT loops | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') | Gunicorn already marks loopback requests https from the header; set it anyway (details) |
| Flask † † | url_for(..., _external=True) builds http://; request.remote_addr is the proxy | Werkzeug ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1) | Gunicorn sets the scheme from loopback; add ProxyFix for the client IP |
| FastAPI / uvicorn † | request.url and url_for use http | --proxy-headers with --forwarded-allow-ips | Handled: proxy headers are on by default and trusted from 127.0.0.1 and ::1 |
| Rails † † | force_ssl redirect loops, cookies without secure | config.assume_ssl = true with config.force_ssl = true | Both are on in a Rails 8 production.rb; add them to older apps |
| Laravel † | url(), asset() and @vite emit http://: stylesheets and scripts blocked | $middleware->trustProxies(at: '127.0.0.1') in bootstrap/app.php | Needed: Canner runs php artisan serve on 127.0.0.1 |
| Next.js † † | Absolute URLs you build yourself (metadata, emails, auth callbacks) point at the wrong origin | Set metadataBase from an env var; for Auth.js, AUTH_TRUST_HOST=true | Auth.js only infers trust on Vercel and Cloudflare Pages, so set it |
| Spring Boot † | Redirects and links use http and the internal port | server.forward-headers-strategy=native | Needed: the default is NONE outside the cloud platforms Spring detects |
† Competitor details come from each vendor’s own published pages on the dates shown; prices and plans change, so check the vendor before you decide. Express (2026-10-29) · Express (2026-10-29) · Django (2026-10-29) · Django (2026-10-29) · Flask (2026-10-29) · Flask (2026-10-29) · FastAPI / uvicorn (2026-10-29) · Rails (2026-10-29) · Rails (2026-10-29) · Laravel (2026-10-29) · Next.js (2026-10-29) · Next.js (2026-10-29) · Spring Boot (2026-10-29)
Don’t trust every proxy
Settings like trust proxy: true, trustProxies(at: '*') or --forwarded-allow-ips='*' believe any X-Forwarded-* header from anyone who can reach the app. Express’s own docs warn that a client can then supply any value unless the last proxy overwrites the headers. On a platform where your app only accepts connections on loopback, trusting loopback is the exact setting.
Content-Security-Policy: upgrade-insecure-requests makes the browser rewrite http:// subresources to https://, including blocked ones. It hides the symptom on the page, but redirects, cookies, emails and API responses still carry the wrong scheme. Fix the framework setting first.
Check what your app thinks it received
Add a temporary route that prints the scheme, host and client IP as your framework sees them, deploy, open it over HTTPS, then delete it. The Express version is below; every framework has an equivalent (request.is_secure() in Django, request.ssl? in Rails, $request->secure() in Laravel). Set the values your app needs with the CLI (canner env set) or the dashboard, and check the app’s output in the logs.
// A throwaway route: what does the app think the request was?
app.get("/_debug/proto", (req, res) => {
res.json({
protocol: req.protocol, // want "https"
secure: req.secure, // want true
host: req.hostname, // want your public hostname
ip: req.ip, // want the visitor's IP, not 127.0.0.1
xfp: req.get("x-forwarded-proto"),
});
});When this isn’t the cause
If the http:// URL is hard-coded (a CMS field, a .env value such as APP_URL=http://…, a database row from before you had HTTPS), no proxy setting changes it. Search the page source for http:// and fix the value at its origin. More framework guides are under hosting by framework.
Questions
What causes mixed content errors on an HTTPS site?
An HTTPS page that loads a script, stylesheet, iframe or fetch() URL over http://. Behind a TLS-terminating proxy, the usual source is the app itself building http:// URLs because it doesn’t trust X-Forwarded-Proto.
What is X-Forwarded-Proto?
A header the proxy adds to say which scheme the visitor used, https or http. The app behind it receives plain HTTP either way, so this header is the only way it can know.
Should I set trust proxy to true in Express?
Only if the proxy in front overwrites X-Forwarded-* on every request. A narrower value is safer: 'loopback' when the proxy runs on the same machine, or the number of hops.
How do I get the real client IP behind a proxy?
Read it from X-Forwarded-For through your framework’s trusted-proxy setting (req.ip in Express with trust proxy, ProxyFix(x_for=1) in Flask, TrustProxies in Laravel), not by parsing the header yourself.
HTTPS handled in front, one setting in your app.
Canner issues the certificates, redirects HTTP and sends the forwarded headers. You add the line your framework needs.