Overview

This document covers the deployment of Ghost 6 on a homelab using Docker Compose, Nginx Proxy Manager (NPM) as the reverse proxy, and Cloudflare Tunnels for public HTTPS access — without exposing any ports on your router.

This document assumes (1) familiarity with docker, (2) an existing Nginx-Proxy-Manager + Cloudflare Tunnels integration. The sample compose.yaml file in the official Ghost GitHub is preloaded with Caddy, and I was really motivated to make it work without it. The same compose.yaml file also includes Tinybird, which is optional so, I've removed it. The compose.yaml file in this write-up is all it takes to bring your Ghost instance online in your homelab.

Infrastructure

ComponentHost
Nginx Proxy Manager + Cloudflare Tunnel192.168.50.10
Ghost + MySQL192.168.50.12

Architecture

Browser
  └── Cloudflare (HTTPS termination)
        └── Cloudflare Tunnel (HTTP → 192.168.50.10:80)
              └── Nginx Proxy Manager
                    └── Ghost (HTTP → 192.168.50.12:2368)

Cloudflare handles TLS with the browser. NPM receives plain HTTP from the tunnel and proxies to Ghost on the other host. Ghost is told to trust the forwarded headers so it knows the original connection was HTTPS.


Docker Compose

Ghost (on host-1:/homelab/ghost/)

services:
  ghost-db:
    image: mysql:8.0
    restart: unless-stopped
    environment:
      MYSQL_ROOT_PASSWORD: ${GHOST_DB_ROOT_PASSWORD}
      MYSQL_USER: ${GHOST_DB_USER:-ghost}
      MYSQL_PASSWORD: ${GHOST_DB_PASSWORD}
      MYSQL_DATABASE: ${GHOST_DB_NAME:-ghost}
    volumes:
      - ./data/mysql:/var/lib/mysql
    healthcheck:
      test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1
      interval: 5s
      timeout: 5s
      retries: 10
      start_period: 30s
    networks:
      - ghost_network

  ghost:
    image: ghost:6-alpine
    restart: unless-stopped
    depends_on:
      ghost-db:
        condition: service_healthy
    environment:
      NODE_ENV: production
      url: https://${DOMAIN:?DOMAIN environment variable is required}
      database__client: mysql
      database__connection__host: ghost-db
      database__connection__user: ${GHOST_DB_USER:-ghost}
      database__connection__password: ${GHOST_DB_PASSWORD}
      database__connection__database: ${GHOST_DB_NAME:-ghost}
      server__trust_proxy: "true"
    ports:
      - "2368:2368"
    volumes:
      - ./data/ghost:/var/lib/ghost/content
    networks:
      - ghost_network

networks:
  ghost_network:

.env (on host-1:~/homelab/ghost/)

DOMAIN=blog.yourdomain.com
GHOST_DB_ROOT_PASSWORD=<strong-password>
GHOST_DB_USER=ghost
GHOST_DB_PASSWORD=<strong-password>
GHOST_DB_NAME=ghost

Nginx Proxy Manager Configuration

Proxy Host Settings

FieldValue
Domain Nameblog.yourdomain.com
Schemehttp
Forward Hostname/IP192.168.50.12
Forward Port2368
Websockets SupportOn
Force SSLOff

Force SSL must be disabled. Since the Cloudflare tunnel connects to NPM over HTTP on port 80, enabling Force SSL causes NPM to redirect that HTTP connection to HTTPS — creating an infinite redirect loop before the request even reaches Ghost.

SSL Tab

FieldValue
SSL CertificateLet's Encrypt
Force SSLOff
HTTP/2 SupportOff
HSTSOff

Custom Nginx Configuration

Leave the custom config field empty, or at most:

proxy_set_header Host blog.yourdomain.com;

The critical header fix (see below) is applied at a lower level via a mounted file, not here.


The Critical Fix: proxy.conf

The Problem

Ghost redirects any HTTP request to its configured HTTPS URL via a 301 Moved Permanently. Since NPM connects to Ghost over HTTP, Ghost always sees an HTTP request and always redirects. The way to break this loop is to pass X-Forwarded-Proto: https to Ghost, which tells it the original client connection was HTTPS. With server__trust_proxy: "true" set in the compose, Ghost respects this header and stops redirecting.

NPM's internal proxy.conf file sets headers for every proxy request, and it hardcodes:

proxy_set_header X-Forwarded-Proto  $scheme;

Since the Cloudflare tunnel connects to NPM over HTTP, $scheme evaluates to http — overriding any https value you set in NPM's custom config field. This is because nginx's header inheritance rules mean that when a location block sets any proxy_set_header directives (which proxy.conf does), all server-level proxy_set_header directives are discarded entirely.

The Fix

Modify NPM's proxy.conf to hardcode https and mount it into the container so the change persists across restarts and updates.

1. Create proxy.conf in your NPM directory:

~/homelab/npm/proxy.conf

Contents:

add_header       X-Served-By $host;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto  https;
proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP          $remote_addr;
proxy_pass       $forward_scheme://$server:$port$request_uri;

The only change from the NPM default is $scheme → https on the X-Forwarded-Proto line.

2. Mount it in your NPM compose (without :ro):

volumes:
  - ./data:/data
  - ./letsencrypt:/etc/letsencrypt
  - ./proxy.conf:/etc/nginx/conf.d/include/proxy.conf

The :ro (read-only) flag must not be used — NPM's startup script modifies files in that directory to enable IPv6 and will crash if it can't write.

3. Restart NPM:

docker compose down && docker compose up -d

Cloudflare Tunnel Configuration

Public Hostname

FieldValue
Subdomainblog
Domainyourdomain.com
TypeHTTP
URL192.168.50.10:80

The tunnel connects to NPM over HTTP on port 80. Do not use HTTPS here — NPM's self-signed backend certificate causes TLS SNI errors with cloudflared, and since Cloudflare already handles TLS with the browser, HTTPS between the tunnel and NPM is unnecessary.

Additional Settings

Leave "HTTP Host Header" blank. Cloudflare will automatically use blog.yourdomain.com as the Host header, which NPM uses to route the request to the correct proxy host.

No TLS Verify should remain at its default (not needed since we're using HTTP).


Email Verification

Once you're online and you can reach your blog by going to https://blog.mydomain.com, the first thing you'll want to do is set up the Admin Panel. You get started by going to https://blog.mydomain.com/ghost (admin URL is customizable, but I won't get into that).

You can get all the way up to this point without setting up your mail client, but Ghost has a built-in Two-Factor Authentication that kicks in when you try to access the Admin Panel on a different device. At this point, you have two options and both use environment variables...

(Recommended) Set up your mail client

environment:
      ...
      # SMTP Settings
      mail__transport: ${MAIL_TRANSPORT}
      mail__options__host: ${MAIL_OPTIONS_HOST}
      mail__options__port: ${MAIL_OPTIONS_PORT}
      # STRICTLY REQUIRED for Port 587:
      mail__options__secure: "false"
      mail__from: "Ghost Blog <${MAIL_OPTIONS_USER}>"
      mail__options__auth__user: ${MAIL_OPTIONS_USER}
      mail__options__auth__pass: ${MAIL_OPTIONS_PASS}

Or temporarily disable staff email verification:

environment:
      ...
      # disable staff device verification
      # This bypasses the email check for logging in from a new device.
      # This should be removed once proper email settings are configured
      security__staffDeviceVerification: false

Ghost ActivityPub (Optional)

Ghost 6 includes ActivityPub federation features. On startup, Ghost attempts to reach https://yourdomain.com/.ghost/activitypub/v1/site/ to retrieve a webhook secret. If your proxy isn't fully working at that moment, you'll see this in the logs:

ERROR Could not get webhook secret for ActivityPub
ERROR No webhook secret found - cannot initialise

This is non-fatal for basic blogging. Once the proxy is correctly configured, the error resolves on next startup. No special NPM routing is needed for /.ghost/activitypub/ — those paths should fall through to Ghost itself, not be proxied to ap.ghost.org. The ap.ghost.org proxy is only for external federation endpoints like /.well-known/webfinger.


What NOT to Do

Do not use the official Ghost Docker compose file as-is. It includes Caddy, an ActivityPub service, a Tinybird analytics service, and custom MySQL init scripts. If you already have NPM as your reverse proxy, none of this is needed. The minimal compose above is sufficient for a fully functional Ghost install.

Do not enable Force SSL in NPM when using a Cloudflare tunnel over HTTP. The tunnel always connects to NPM over HTTP, so Force SSL will redirect every request before NPM can proxy it, causing an infinite loop.

Do not omit server__trust_proxy: "true" from your Ghost compose. Without it, Ghost ignores the X-Forwarded-Proto header entirely and will always redirect HTTP→HTTPS regardless of what NPM sends.

Do not mount proxy.conf as read-only (:ro). NPM's startup script modifies files in /etc/nginx/conf.d/include/ to enable IPv6 support and will fail to start if it can't write to that directory.


Troubleshooting Reference

Too Many Redirects

The redirect loop almost always means one of three things:

  1. server__trust_proxy is not set in Ghost's environment — verify with docker exec ghost-ghost-1 printenv | grep trust
  2. NPM is sending X-Forwarded-Proto: http — verify the proxy.conf fix is applied with docker exec nginx-proxy-manager cat /etc/nginx/conf.d/include/proxy.conf
  3. Force SSL is enabled in NPM — disable it

NPM Default Page Showing Instead of Ghost

Run from the NPM host:

curl -H "Host: blog.yourdomain.com" http://192.168.50.12:2368/

If you get a 301 redirect, server__trust_proxy is not working. If you get Connection refused, Ghost isn't running or port 2368 is firewalled on the Ghost host.

Ghost Container Unhealthy

The default healthcheck curl -f http://localhost:2368/ghost/ fails because Ghost redirects HTTP to HTTPS internally. Either remove the healthcheck or use:

healthcheck:
  test: ["CMD", "wget", "-q", "--spider", "http://localhost:2368/ghost/api/admin/site/"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 60s

Database Errors on First Start

Ghost and MySQL initialize simultaneously. Ghost will throw ECONNREFUSED errors on the first few attempts while MySQL is still initializing — this is normal. The depends_on: condition: service_healthy with a proper MySQL healthcheck handles this. If Ghost crashes before MySQL becomes healthy, it will restart and succeed once MySQL is ready.

Ensure the database name is consistent between MYSQL_DATABASE in the db service and database__connection__database in the Ghost service. A mismatch causes ER_DBACCESS_DENIED_ERROR.