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
| Component | Host |
|---|---|
| Nginx Proxy Manager + Cloudflare Tunnel | 192.168.50.10 |
| Ghost + MySQL | 192.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
| Field | Value |
|---|---|
| Domain Name | blog.yourdomain.com |
| Scheme | http |
| Forward Hostname/IP | 192.168.50.12 |
| Forward Port | 2368 |
| Websockets Support | On |
| Force SSL | Off |
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
| Field | Value |
|---|---|
| SSL Certificate | Let's Encrypt |
| Force SSL | Off |
| HTTP/2 Support | Off |
| HSTS | Off |
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
| Field | Value |
|---|---|
| Subdomain | blog |
| Domain | yourdomain.com |
| Type | HTTP |
| URL | 192.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: falseGhost 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:
server__trust_proxyis not set in Ghost's environment — verify withdocker exec ghost-ghost-1 printenv | grep trust- NPM is sending
X-Forwarded-Proto: http— verify the proxy.conf fix is applied withdocker exec nginx-proxy-manager cat /etc/nginx/conf.d/include/proxy.conf - 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.