Part of Hosting by frameworkHosting · Networking

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 secure is 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-For being ignored.
ResourceWhat the browser doesWhat 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 requestA 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:

HeaderValueNotes
Host †The hostname the visitor typedPassed through unchanged: your-project.canner.app or your custom domain
X-Forwarded-Proto †httpsSet by Caddy. Plain HTTP requests are redirected to HTTPS before they reach the app
X-Forwarded-Host †Same as HostCaddy doesn’t rewrite Host, so you rarely need this one
X-Forwarded-For †The visitor’s IPOne 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
FrameworkWithout the settingThe settingOn Canner
Express † †req.protocol is http, req.secure is false, req.ip is the proxy; express-session won’t set a secure cookieapp.set('trust proxy', 'loopback')Needed: Express trusts no proxy by default
Django † †request.is_secure() false: CSRF 403s, SECURE_SSL_REDIRECT loopsSECURE_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 proxyWerkzeug 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-ipsHandled: proxy headers are on by default and trusted from 127.0.0.1 and ::1
Rails † †force_ssl redirect loops, cookies without secureconfig.assume_ssl = true with config.force_ssl = trueBoth 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.phpNeeded: 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 originSet metadataBase from an env var; for Auth.js, AUTH_TRUST_HOST=trueAuth.js only infers trust on Vercel and Cloudflare Pages, so set it
Spring Boot †Redirects and links use http and the internal portserver.forward-headers-strategy=nativeNeeded: 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.