How To Configure Ghost Behind A Reverse Proxy (Nginx & HAProxy or Traefik)

Configure Ghost to work with HTTPS behind a local network proxy.

One pitfall I encountered, that I suppose many other self-hosting enthusiasts have as well, is how to configure Ghost to work correctly with SSL behind a reverse proxy. Turns out, it is quite simple! All one has to do is inject proper forwarded for headers for every step in the chain, and set the base url in the config.production.json to be https.

The assumptions I am making here is that you are on a RHEL family Linux distro. If you use a Debian family Linux distro or other *nix distro please sub out the appropriate config file locations respective to your system.

In example, I use a Traefik for my main load-balancer and SSL termination endpoint for my local network, then I use Nginx on my Ghost server to proxy the traffic to the NodeJS backend. The traffic flow looks like this:

Internet -> Cloudflare -> Firewall -> Traefik -> Nginx -> Ghost

Traefik

I manage my Traefik config with an automation tool (SaltStack), and use the dynamic file provider for all my various services. Below is a snipped of my  traefik.yml config for the core and Ghost bits. Including, the Cloudflare bits to work with LetsEncrypt. This guide will not cover that specific portion but includes it as a reference so you know what to put in there for Traefik because it was annoying to get working because every guide out there only covers Docker. Lastly, I use Traefik as a managed Systemd service and not as a Docker container.

# /usr/lib/systemd/system/traefik.service

[Unit]
Description=traefik proxy
After=network-online.target
Wants=network-online.target systemd-networkd-wait-online.service

[Service]
Restart=on-abnormal

; Get environmental variables
EnvironmentFile=/etc/environment

; User and group the process will run as.
User=traefik
Group=traefik

; Always set "-root" to something safe in case it gets forgotten in the traefikfile.
ExecStart=/usr/local/bin/traefik --configfile=/etc/traefik/traefik.yml

; Limit the number of file descriptors; see `man systemd.exec` for more limit settings.
LimitNOFILE=1048576

; Use private /tmp and /var/tmp, which are discarded after traefik stops.
PrivateTmp=true
; Use a minimal /dev (May bring additional security if switched to 'true', but it may not work on Raspberry Pi's or other devices, so it has been disabled in this dist.)
PrivateDevices=false
; Hide /home, /root, and /run/user. Nobody will steal your SSH-keys.
ProtectHome=true
; Make /usr, /boot, /etc and possibly some more folders read-only.
ProtectSystem=full
; … except /etc/ssl/traefik, because we want Letsencrypt-certificates there.
;   This merely retains r/w access rights, it does not add any new. Must still be writable on the host!
ReadWriteDirectories=/etc/traefik/acme

; The following additional security directives only work with systemd v229 or later.
; They further restrict privileges that can be gained by traefik. Uncomment if you like.
; Note that you may have to add capabilities required by any plugins in use.
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=true

[Install]
WantedBy=multi-user.target
# /etc/traefik/traefik.yml

  global:
    checkNewVersion: true
    sendAnonymousUsage: false
  serversTransport:
    insecureSkipVerify: true
  log:
    level: info
    filePath: /var/log/traefik/traefik.log
  accessLog:
    filePath: /var/log/traefik/access.log
  api:
    insecure: true
  entryPoints:
    web:
      address: :80
    websecure:
      address: :443
  certificatesResolvers:
    letsencrypt:
      acme:
        email: me@example.com
        storage: /etc/traefik/acme/acme.json
        keyType: EC384
        dnsChallenge:
          provider: cloudflare
          delayBeforeCheck: 0
  providers:
    file:
      directory: /etc/traefik/dynamic/
  http:
    routers:
      blog:
        rule: "Host(`blog.example.com`)"
        service: ghost
        tls:
          certResolver: letsencrypt
    services:
      ghost:
        loadBalancer:
          servers:
            - url: http://<hostname>.<subdomain>.<domain>.<tld>:80

And don't forget to setup a logrotate for the Traefik log files! This should be enough to get you started.

# /etc/logrotate.d/traefik

/var/log/traefik/*.log {
  size 5M
  rotate 5
  missingok
  notifempty
  compress
  dateformat .%Y-%m-%d
  postrotate
    /bin/systemctl kill --signal="USR1" traefik
  endscript
}

HAProxy

Haproxy config snippets /etc/haproxy/haproxy.cfg:

Frontend Section

frontend http_redirectTo_https
    mode http
    bind :80
    acl http ssl_fc,not
    http-request set-header X-Forwarded-Protocol http if http
    http-request redirect scheme https

frontend main
    mode http
    bind :443 ssl crt /etc/letsencrypt/live/<domain>/fullcert.pem

    # set https forward
    acl https ssl_fc
    http-request set-header X-Forwarded-Protocol https if https

    # Add a X-Forwarded-For containing the client IP address if not present
    acl h_xff_exists req.hdr(X-Forwarded-For) -m found
    http-request add-header X-Forwarded-For %[src] unless h_xff_exists
    http-request set-header X-Forwarded-Port %[dst_port]
    http-request add-header X-Forwarded-Proto https if { ssl_fc }

    # custom service backends
    acl root_dir path_reg ^$|^/$

    # Ghost Blog
    acl host_blog hdr(host) -i <blog domain>
    use_backend blog if host_blog HTTP

Backend Section

# Ghost
backend blog
    balance roundrobin
    mode http
    server blog <ip or fqdn>:<port>

Nginx

Then on my Ghost server /etc/nginx/conf.d/<site>.conf:

Note: you must hardcode the X-Forwarded-Proto header to https

server {
    listen 80;

    server_name <site url>;

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:<ghost port>;
	proxy_buffering off;

    }

    location ~ /.well-known {
        allow all;
    }

    client_max_body_size 0;
}

Lastly, in the config.production.json file under the Ghost root directory, ensure the url key is HTTPS.

"url": "https://<site url>"

Then do a ghost restart and now Ghost will properly understand that it is an SSL enabled site.

Cheers.