Ghost http vs https behind reverse proxy

I have the exact same issues as described in this post: Ghost portal membership not working , however, that thread ends without a final resolution and I’m stuck on the same last question on how to configure behind a reverse proxy.

  • What’s your URL? This is the easiest way for others to help you

Server will likely be offline, due to the issue, but the content at the link above is virtually identical to my situation.

  • What version of Ghost are you using? If it’s not the latest, please update Ghost first before opening your topic

5.35.1

  • How was Ghost installed and configured?

Ghost is running currently as 3 distinct instances in 3 different docker files.

  • What Node version, database, OS & browser are you using?

Node 16.19.1, mysql 8 (Amazon RDS), Alpine Linux, Safari/Chome

  • What errors or information do you see in the console?

Same conditions as in prior thread. Reverse proxy from https->http results in failures in sign-ups and portal customization, despite still being hosted under nginx HTTPS certificate. Attempting to use ghost https results in handshaking issues with SSL and site not accessible at all.

  • What steps could someone else take to reproduce the issue you’re having?

It took me about two weeks to get everything working with nginx, so direct reproduction might be a bit much without transfering “scrubbed” versions of all of my configuration files. Though in principal it’s just three docker ghost instances and one docker nginx instance. The reverse proxy setup was the challenging part as there’s dozens of ways to configure it. Though everything seems to be performing as expected now on the nginx side. I haven’t found any examples of internal docker redirects with nginx and https, given it’s all running on the same internal shared network address across the docker instances.

Background…
I’m running multiple Ghost instances behind an nginx reverse proxy. The nginx reverse proxy is handling the SSL certificates and automated maintenance of those certs. All of the ghost instances and nginx are running in their own docker instances with a shared docker network. So nginx is dispatching requests over http internal to the docker network. Only nginx is exposed on port 80 and 443 (except when enabled for my debug testing).

Ghost seems to be making internal decisions based on its “url” parameter. So even if nginx is setup for https only, it assumes its insecure and portal/sign-up doesn’t function as described in the linked post above.

If I change the ghost url to include https, then nginx can’t redirect to the ghost instances as it fails SSL handshaking.

Given nginx already manages all of my letsencrypt certificates centrally and I will have other content served under the same domain it doesn’t make sense to let ghost manage the letsencrypt process on its own.

If it’s possible to point ghost at my existing certificates, that may help with the SSL handshaking issue, but that’s getting pretty deep into the weeds of the cert management process.

Editted:

Here is a clean copy of the nginx.conf file.

upstream siteone {
    server ghost1:8030;
}

upstream sitetwo {
    server ghost2:8031;
}

upstream sitethree {
    server ghost3:8032;
}

server {
    listen 80;
    listen [::]:80;
    server_name siteone.com sitetwo.com sitethree.com;
    location / {
        return 301 https://$host$request_uri;
    }
    location ~ /.well-known/acme-challenge {
        allow all;
        root /tmp/acme_challenge;
    }
}
server {
    listen 443 ssl;
    listen [::]:443 ssl http2;
    server_name sitethree.com;
    ssl_certificate /etc/letsencrypt/live/sharedkey/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sharedkey/privkey.pem;

    location / {
        proxy_pass http://sitethree;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl http2;
    server_name sitetwo.com;
    ssl_certificate /etc/letsencrypt/live/sharedkey/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sharedkey/privkey.pem;

    location / {
        proxy_pass http://sitetwo;
        proxy_set_header X-Forwarded-Proto $scheme;        
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }
}

server {
    listen 443 ssl;
    listen [::]:443 ssl http2;
    server_name siteone.com;
    ssl_certificate /etc/letsencrypt/live/sharedkey/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/sharedkey/privkey.pem;

    location / {
        proxy_pass http://siteone;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_redirect off;
    }
}

Here is a clean copy of the docker-compose.yaml being used to host the service.

version: "3.3"

services:
  nginx:
    container_name: 'nginx-service'
    build:
      context: .
      dockerfile: docker/nginx.Dockerfile
    ports:
      - 80:80
      - 443:443
    volumes:
      - ./config:/etc/nginx/conf.d
      - /etc/letsencrypt:/etc/letsencrypt:ro
      - /tmp/acme_challenge:/tmp/acme_challenge
    networks:
      - app
    restart: always

  ghost1:
    container_name: 'ghost1'
    image: ghost:alpine
    restart: always
    expose:
      - "8030"
    ports:
      - 8030:8030
    environment:
      database__client: mysql
      database__connection__host: <aws_address>.rds.amazonaws.com
      database__connection__user: admin
      database__connection__password: <mysql_password>
      database__connection__database: ghostone
      url: http://siteone.com
      NODE_ENV: production
    volumes:
      - /home/ec2-user/ghost1/content:/var/lib/ghost/content
      - /home/ec2-user/ghost1/config.production.json:/var/lib/ghost/config.production.json:z
    networks:
      - app

  ghost2:
    container_name: 'ghost2'
    image: ghost:alpine
    restart: always
    expose:
      - "8031"
    ports:
      - 8031:8031
    environment:
      database__client: mysql
      database__connection__host: <aws_address>.rds.amazonaws.com
      database__connection__user: admin
      database__connection__password: <mysql_password>
      database__connection__database: ghosttwo
      url: http://sitetwo.com
      NODE_ENV: production
    volumes:
      - /home/ec2-user/ghost2/content:/var/lib/ghost/content
      - /home/ec2-user/ghost2/config.production.json:/var/lib/ghost/config.production.json:z
    networks:
      - app

  ghost3:
    container_name: 'ghost3'
    image: ghost:alpine
    restart: always
    expose:
      - "8032"
    ports:
      - 8032:8032
    environment:
      database__client: mysql
      database__connection__host: <aws_address>.rds.amazonaws.com
      database__connection__user: admin
      database__connection__password: <mysql_password>
      database__connection__database: ghostthree
      url: http://sitethree.com
      NODE_ENV: production
    volumes:
      - /home/ec2-user/ghost3/content:/var/lib/ghost/content
      - /home/ec2-user/ghost3/config.production.json:/var/lib/ghost/config.production.json:z
    networks:
      - app

networks:
  app:
    driver: bridge

You can choose not to set up Let’s Encrypt with Ghost during installation, and use your preferred method, e.g., ghost setup --no-setup-ssl. Likewise, you have the flexibility with Nginx, too.

Can you share the nginx config that proxies requests to a Ghost instance?
If you don’t include proxy_set_header X-Forwarded-Proto $scheme; in the proxy block, Ghost will assume that you didn’t request the site over SSL and redirect the request. Note: Ghost doesn’t do SSL termination.

Aside: the cli internally uses this nginx template to configure nginx if you’re interested!

I’ve updated my original post with clean copies of the nginx.conf and docker-compose.yaml files. Just the domain names are changed and I left artifacts from my testing and debugging in case I’ve screwed up something else that I haven’t noticed, or if something looks odd.

I added the Proto Scheme command as you suggested, but it didn’t have any obvious impact.

The main thing I’m focusing on for the SSL working is that the Stripe warning (and a couple of other locations) indicate its an insecure site. It’s possible the blank Portal problems may not be related, but the prior post that I linked above seemed to suggest both failures were related to the https issue.

Thank you for this reference. I haven’t previously run the SSL configuration for this instance as I was intending on using the reverse proxy from the start. It’s possible it was setup in the default image, but I hadn’t seen any evidence other than that I could force the request over https…though that request fails. I hadn’t gotten to the stage of trying to trace the setup code as I was debugging my nginx setup and didn’t think Ghost was the direct cause of the issue, just a side-effect interaction.

Optimally I may eventually be using Stripe and other features, so I do want to get the site self aware that it’s securely hosted. I’m also not clear if the Portal coming up blank is because of the insecure state being reported as suggested in the previous post, or if its an unrelated defect.

I’m not entirely clear what the problem is. However, if you installed Ghost using ghost-cli, it should be clear what steps you took; if you used an image provided by your host, then that probably used the default options. For full control, always use the official steps.

Nonetheless, Nginx configuration is straightforward to set it up with your certificates. First, you need to ensure config.production.json includes the correct domain and https. Then note the port Ghost runs on.

For Nginx, this is all you need in sites-available. Once you’ve done this, create a symbolic link to sites-enabled, check the configuration, and reload Nginx.

server {
    listen 80;
    listen [::]:80;

    server_name your.domain;
    return 301 https://your.domain$request_uri;
}

server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;

    server_name your.domain;

    access_log /var/log/nginx/your_domain.log;
    error_log /var/log/nginx/your_domain.log;

    ssl_certificate /etc/ssl/certs/your_domain-cert.pem;
    ssl_certificate_key /etc/ssl/private/your_domain-key.pem;

    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:your_port;

        add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
        add_header X-Content-Type-Options "nosniff" always;
        add_header X-Frame-Options SAMEORIGIN always;
        add_header X-XSS-Protection "1; mode=block" always;
    }

    client_max_body_size 50m;
}

Thanks again. I started with the image, so yes, assuming default options.

I’ll experiment with the nginx options you suggested.

I ran into a lot of issues anytime I deviated from the server { 80 } → server { 443 } → upstream {} blocks, the latter which link directly to the docker ghost instances, which were present in the original templates I started with. I also had issues with using 127.0.0.1 and found several references that this sort of reference would lock the call within the nginx container instead of bridging to the domain instance containers. Unfortunately most of the nginx reverse proxy documentation and references I found were for blending docker services within a single domain, as opposed to hosting multiple domains via docker.

I’ll also test directly mapping my certs into the ghost instances. Are there any other settings within the Ghost instance? Or am I supposed to run the SSL setup and specify I already have certs at some point in the process? (I’ve avoided running it to not break anything with my LetsEncrypt setup already in place)

I use Docker for Plausible analytics and proxy this through a natively installed Nginx, and it works fine with what is essentially the same configuration I shared for Ghost.

But I see little benefit installing Ghost in a container; I’d rather install database and Ghost on the host.

Unfortunately, I can’t help much with Ghost running in Docker.

You have given me a lot of valuable information. I’m still figuring this stuff out as well.

My main reasons for using Docker in this scenario are the fact I’m running multiple instances of Ghost side-by-side on the same EC2 host. And “in principal” it’s much easier and more secure to manage the individual instances as I never have to mess with the host server configuration or install additional software as its all contained in the docker instances.

It’s just a matter of getting the software in the Docker instances talking to each other.

Thanks again for the help.

I have simmilar issues running multiple instances in K8S cluster.

I’m also using nginx with letsencrypt. That traffic is then beeing forwarded to K8S cluster.

There is only one anoying issue:
If I don’t set url env variable forum works but some of the URLs in the portal are going to be saved as http://localhost:2368. Which is annoying as none of the previews work and uploaded images are uplodade correctly but in the posts they are refferenced with local host.

If I set url env variable to my https://domain then the blog does not work at all as all the traffic is redirected over and over again until it errors out from too many redirects.

Why is there no way to just set publicUrl variable or simmilar which (when set) would be used to reference content instead of default localhost:2368. In container the blog still needs to run on localhost:2368.

Solved it.
Here is the config for nginx with SSL termination.

server {
	listen 80;
	server_name blog.domain.com;

	location ^~ /.well-known/acme-challenge/ {
		allow all;
		default_type "text/plain";
		alias /var/www/.well-known/acme-challenge/;
	}
	
	location = /.well-known/acme-challenge/ {
		return 404;
	}

	location / {
		return 301 https://$host$request_uri;
	}
}

server {
	listen 443 ssl http2;
	server_name blog.domain.com;
	access_log /var/log/nginx/blog.domain.com.access.log;
	error_log /var/log/nginx/blog.domain.com.error.log;
	client_max_body_size 10M;
	ssl_dhparam /etc/nginx/dhparam.pem;
	ssl_prefer_server_ciphers off;
	ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
	ssl_protocols TLSv1.2 TLSv1.3;
	add_header Strict-Transport-Security "max-age=63072000" always;
	ssl_certificate /var/lib/acme/live/blog.domain.com/fullchain;
	ssl_certificate_key /var/lib/acme/live/blog.domain.com/privkey;
	
	location / {
		proxy_pass http://blog-upstream;
		proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504;
		proxy_redirect off;
		proxy_buffering off;
		proxy_ssl_verify off;
		proxy_set_header X-Forwarded-Proto $scheme;
		proxy_set_header Host $http_host;
		proxy_set_header X-Real-IP $remote_addr;
		proxy_set_header X-NginX-Proxy true;
		proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
		proxy_set_header X-Forwarded-Ssl on;
	}
}