Ghost Behind a Reverse Proxy: Loops, Images, Logins

Ghost expects a proxy in front of it. Two settings decide whether it works: the site URL and the forwarded-protocol header. What breaks when either is wrong.

Luxe Themes 9 min read

Quick answer: Ghost is built to sit behind a reverse proxy. It listens on a local port over plain http and expects something in front of it to handle the certificate. Two things then decide whether it works. Ghost’s site URL must be the public https address, and the proxy must tell Ghost the original request was secure through the forwarded-protocol header. Get the first wrong and images break or the admin refuses to log in. Get the second wrong and every request becomes a redirect to itself.

You put a proxy in front of Ghost. Perhaps a control panel did it for you, perhaps it is a NAS, a Docker setup with Traefik or Nginx Proxy Manager, or Cloudflare in front of everything. The site half works. It redirects forever, or the pages load without images, or the admin greets you with an error about origins. All three have the same two causes, and this guide explains them in the order you can fix them.

We run our own sites this way, Ghost in containers behind a proxy that terminates the certificate. We tested how Ghost answers the same request with and without the header on one of them. Ghost’s own proxy documentation covers the header; this guide adds the rest.

How Ghost sees a request through a proxy

Ghost does not speak https itself. It listens on a local port, by default 2368 on the machine’s own address, and answers plain http. The proxy in front of it holds the certificate, takes the visitor’s https request, and passes it to Ghost as http. From Ghost’s point of view every request now looks insecure and arrives from the proxy rather than from the visitor.

To behave correctly, Ghost needs two facts it cannot see for itself:

  • Its own public address. Ghost builds every link, image address, canonical tag and admin redirect from the site URL in its configuration. On a Ghost-CLI install it is the url value in the config file; on Ghost’s Docker Compose setup it comes from the domain in the .env file; on other Docker images it is usually an environment variable called url.
  • Whether the visitor’s request was secure. The proxy passes this along in a header named X-Forwarded-Proto, with the value https. Ghost trusts that header from the proxy and treats the request as secure.

Everything below is one of those two facts being wrong.

The redirect loop: the header is missing

When Ghost’s site URL starts with https but a request arrives looking like http, Ghost does the sensible thing and redirects the visitor to the https address. If the proxy did not forward the protocol, every request looks like http, so every request gets that redirect. The proxy passes it to the browser, the browser follows it, the proxy strips the protocol again, and Ghost redirects again. The browser gives up with “too many redirects”.

We sent the same request to Ghost directly on one of our own sites, changing only that header:

no forwarded header -> 301 https://example.com/
X-Forwarded-Proto: http -> 301 https://example.com/
X-Forwarded-Proto: https -> 200 (the page)

That is the entire mechanism. Ghost’s documentation says the same in one sentence. Set the header incorrectly and Ghost “will think the requests are insecure, attempt to redirect to the https version of a URL and cause an infinite redirect loop”.

The fix is to make the proxy forward the header, and each proxy has its own way:

ProxyWhat to do
nginxAdd the four header lines Ghost-CLI writes itself, shown below.
CaddyWhen Caddy terminates the certificate it forwards the header on its own. When another proxy sits in front of Caddy, Caddy must be told not to redirect: turn its automatic https off, listen on port 80, and pass X-Forwarded-Proto https upstream.
TraefikForwards the header when it terminates the certificate. Loops usually come from a second proxy behind it, such as the Caddy inside Ghost’s Docker setup, still trying to redirect.
Nginx Proxy ManagerForwards the header by default. If a loop appears, check that the scheme in the proxy host entry is http and that Ghost’s URL is https.
CloudflareUse the Full (strict) encryption mode. Flexible sends plain http to your server, and Ghost redirects it back to https forever.

For the technical reader, this is the block Ghost-CLI writes for nginx, and it is what every other proxy has to imitate:

location / {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $http_host;
proxy_pass http://127.0.0.1:2368;
}

The second symptom looks different. The page loads, the text is there, and the images are blank, or links point at http:// or at localhost. Nothing is wrong with the proxy. Ghost is building addresses from a site URL that does not match how the site is reached.

The common versions are two. A URL that starts with http while the site is served over https, and a URL still set to the default local address because the installer or the Docker image never asked. In the first case the browser is on an https page and refuses to load http images, which it calls mixed content. In the second, every link on the page points at a machine the visitor cannot reach.

The fix is the setting itself. Ghost’s configuration documentation puts it plainly: if you use SSL, always enter the URL with https://. On a Ghost-CLI install:

Terminal window
ghost config url https://example.com
ghost restart

On Ghost’s Docker Compose setup, change the domain in .env and recreate the containers. On another Docker image, change the url environment variable and restart the container. There is no need to re-upload images: Ghost stores uploaded image addresses relative to the site URL, so correcting the setting corrects every image at once.

The admin will not log in: the origin does not match

The third symptom is the most alarming, because the site itself works. You open the admin, enter your details, and get “Request made from incorrect origin”, followed by two addresses: the one Ghost expected and the one the browser sent. Ghost checks that admin requests come from the address it is configured with, and refuses when they do not. The message tells you exactly which setting is wrong, because the expected address is Ghost’s site URL, or its separate admin URL if one is set.

The cause is the same site URL as above, seen from the admin side. A default of http://localhost:2368 is the classic case: the site is reached at your real domain, Ghost expects localhost, and every admin request is rejected. Fix the URL as in the previous section and the admin logs in.

Two related messages point the same way. “Access denied from url” means the site URL in the configuration does not match the address being used, and Ghost’s documentation says so directly. And if you run the admin on its own hostname, such as admin.example.com, Ghost needs that told separately. The admin URL option in the config, or the admin domain in the Docker setup, does that; without it the origin check fails for the admin alone.

Uploads that fail part way

One more thing a proxy adds, unrelated to the two settings. Proxies limit the size of a request body, and the default is often small enough to reject a theme zip or a large image. Ghost-CLI’s own nginx configuration raises the limit to 50 MB with a single line, and Ghost’s proxy documentation reminds you to do the same on any other proxy. If uploads of small images work and larger ones fail with a generic error, this is the first place to look.

For the technical reader: on nginx the line is client_max_body_size 50m; inside the server block. Other proxies have an equivalent setting under a similar name.

Using your own proxy with Ghost’s Docker Compose setup

Ghost’s Docker Compose setup ships with its own Caddy, which obtains certificates and forwards the right headers. If a proxy already sits in front of the server, running two of them is where loops come from. The arrangement that self-hosters have settled on has three parts. Remove the Caddy service from the compose file, publish the Ghost container’s port to the proxy, and have the proxy forward the standard headers to it.

Two things carry over from Caddy’s job. The site URL still has to be the public https address, which the .env domain provides. And if you use the social web features, the three ActivityPub paths that Caddy routed to the ActivityPub service now need routing at your proxy instead. Our ActivityPub guide lists them.

When something still doesn’t work

What you seeThe setting to check
Endless redirect, “too many redirects”The proxy is not forwarding X-Forwarded-Proto: https, or Cloudflare is in Flexible mode
Page loads, images blank, browser console mentions mixed contentSite URL starts with http
Links go to localhost or a wrong hostnameSite URL is still the default
”Request made from incorrect origin” in adminSite URL, or admin URL, does not match the address in the browser
”Access denied from url”Site URL does not match the address in use
Theme or image uploads fail above a certain sizeProxy body size limit
Site works on http but https loops, only with CloudflareEncryption mode is Flexible; use Full (strict)

A useful habit: after any change to the URL or the proxy, request the home page and the admin page from outside the server and look at the raw responses. One redirect from http to https is correct. A redirect from https to https is the loop. Images pointing at a different scheme or host than the page are the URL.

Where this leaves you

Ghost behind a proxy comes down to a URL that says https and a header that says https. With both in place the site, the images and the admin agree on one address, and everything else in Ghost works as it would without the proxy. Our Ghost themes are developed and demoed on sites that run exactly this way. If you are still choosing how to run Ghost, the self-hosting guide compares the setups. If the domain or certificate is the part still open, the custom domain guide picks up from here.

Frequently Asked Questions

Why does my Ghost site redirect forever behind a proxy?
Because the proxy is not telling Ghost that the original request was secure. Ghost only sees plain http from the proxy, decides the visitor should be on https, and redirects. The proxy passes that redirect on, the browser follows it, and the cycle repeats. Forward the X-Forwarded-Proto header with the value https and the loop stops.
Why are images not loading on my self-hosted Ghost site?
Almost always because the site URL in Ghost's configuration is wrong, usually http instead of https or still the default localhost address. Ghost builds every image address from that setting, so a browser on an https page refuses the http images. Set the URL to the public https address and restart Ghost.
What does 'Request made from incorrect origin' mean in Ghost?
Ghost compares the address the browser sent the admin request from with the address in its own configuration, and they differ. The message shows both. Set Ghost's site URL, or its separate admin URL if you use one, to the address you actually open in the browser.
Do I need Ghost's Caddy if I already run a reverse proxy?
No. Ghost's Docker Compose setup includes Caddy to handle certificates, but if a proxy already sits in front of your server, remove the Caddy service, point your proxy at the Ghost container, and forward the standard headers. If you use ActivityPub, route its three paths to the ActivityPub service as well.

Keep reading