1. The problem I’m having:
I’m trying to configure Caddy with Cloudflare Tunnel and Ghost CMS. Caddy is part of the Ghost self-host Docker setup. The Docker containers for this configuration are running inside an Ubuntu server VM on a small dedicated server in my local network. The Cloudflare container is running on a separate VLAN. The server VLAN is configured to allow all traffic on all ports to the Ghost CMS VM IP.
I have a domain configured with Cloudflare to connect to an app on a Docker host VM on the same server. The configuration is very simple. For example, Immich is accessed through https://immich.mydomain.com. In this case, I point the Cloudflare Tunnel to http://local-vm-ip:immich-port.
For the Ghost CMS instance, it doesn’t work that way. Caddy is required for Ghost to provide full functionality, so I can’t remove Caddy from the configuration.
I tried pointing the Cloudflare route ghost.mydomain.com to https://local-vm-ip, but it simply doesn’t work. I get a 502 response when I curl https://ghost.mydomain.com.
Below are the Docker Compose file, Caddy config file, and .env file examples.
2. Error messages and/or full log output:
$: curl -I https://ghost.xxxxxxxxx.xxx
HTTP/2 502
date: Sun, 01 Feb 2026 00:12:35 GMT
content-type: text/plain; charset=UTF-8
content-length: 15
cache-control: private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0
expires: Thu, 01 Jan 1970 00:00:01 GMT
referrer-policy: same-origin
x-frame-options: SAMEORIGIN
server: cloudflare
cf-ray: 9c6d3cf31fb26af7-SJC
alt-svc: h3=":443"; ma=86400
3. Caddy version:
image: caddy:2.10.2-alpine@sha256:953131cfea8e12bfe1c631a36308e9660e4389f0c3dfb3be957044d3ac92d446
4. How I installed and ran Caddy:
a. System environment:
Caddy is part of the docker self-host configuration provided by ghost cms
b. Command:
docker compose up
c. Service/unit/compose file:
---
# yaml-language-server: $schema=https://raw.githubusercontent.com/compose-spec/compose-spec/main/schema/compose-spec.json
services:
caddy:
image: caddy:2.10.2-alpine@sha256:953131cfea8e12bfe1c631a36308e9660e4389f0c3dfb3be957044d3ac92d446
restart: always
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN:?DOMAIN environment variable is required}
ADMIN_DOMAIN: ${ADMIN_DOMAIN:-}
ACTIVITYPUB_TARGET: ${ACTIVITYPUB_TARGET:-https://ap.ghost.org}
volumes:
- ./caddy:/etc/caddy
- caddy_data:/data
- caddy_config:/config
depends_on:
- ghost
networks:
- ghost_network
ghost:
# Do not alter this without updating the Tinybird Sync container as well
image: ghost:${GHOST_VERSION:-6-alpine}
restart: always
# This is required to import current config when migrating
env_file:
- .env
environment:
NODE_ENV: production
url: https://${DOMAIN:?DOMAIN environment variable is required}
admin__url: ${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}
database__client: mysql
database__connection__host: db
database__connection__user: ${DATABASE_USER:-ghost}
database__connection__password: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
database__connection__database: ghost
tinybird__tracker__endpoint: https://${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit
tinybird__adminToken: ${TINYBIRD_ADMIN_TOKEN:-}
tinybird__workspaceId: ${TINYBIRD_WORKSPACE_ID:-}
tinybird__tracker__datasource: analytics_events
tinybird__stats__endpoint: ${TINYBIRD_API_URL:-https://api.tinybird.co}
volumes:
- ${UPLOAD_LOCATION:-./data/ghost}:/var/lib/ghost/content
depends_on:
db:
condition: service_healthy
tinybird-sync:
condition: service_completed_successfully
required: false
tinybird-deploy:
condition: service_completed_successfully
required: false
activitypub:
condition: service_started
required: false
networks:
- ghost_network
db:
image: mysql:8.0.44@sha256:f37951fc3753a6a22d6c7bf6978c5e5fefcf6f31814d98c582524f98eae52b21
restart: always
expose:
- "3306"
environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required}
MYSQL_USER: ${DATABASE_USER:-ghost}
MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
MYSQL_DATABASE: ghost
MYSQL_MULTIPLE_DATABASES: activitypub
volumes:
- ${MYSQL_DATA_LOCATION:-./data/mysql}:/var/lib/mysql
- ./mysql-init:/docker-entrypoint-initdb.d
healthcheck:
test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1
interval: 1s
start_period: 30s
start_interval: 10s
retries: 120
networks:
- ghost_network
traffic-analytics:
image: ghost/traffic-analytics:1.0.42@sha256:c927127b0e79ef92093ed553a47a26c99693714c94729561ba0bda70eccda8ba
restart: always
expose:
- "3000"
volumes:
- traffic_analytics_data:/data
environment:
NODE_ENV: production
PROXY_TARGET: ${TINYBIRD_API_URL:-https://api.tinybird.co}/v0/events
SALT_STORE_TYPE: ${SALT_STORE_TYPE:-file}
SALT_STORE_FILE_PATH: /data/salts.json
TINYBIRD_TRACKER_TOKEN: ${TINYBIRD_TRACKER_TOKEN:-}
LOG_LEVEL: debug
profiles: [analytics]
networks:
- ghost_network
activitypub:
image: ghcr.io/tryghost/activitypub:1.1.0@sha256:39c212fe23603b182d68e67d555c6b9b04b1e57459dfc0bef26d6e4980eb04d1
restart: always
expose:
- "8080"
volumes:
- ${UPLOAD_LOCATION:-./data/ghost}:/opt/activitypub/content
environment:
# See https://github.com/TryGhost/ActivityPub/blob/main/docs/env-vars.md
NODE_ENV: production
MYSQL_HOST: db
MYSQL_USER: ${DATABASE_USER:-ghost}
MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
MYSQL_DATABASE: activitypub
LOCAL_STORAGE_PATH: /opt/activitypub/content/images/activitypub
LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub
depends_on:
db:
condition: service_healthy
activitypub-migrate:
condition: service_completed_successfully
profiles: [activitypub]
networks:
- ghost_network
# Supporting Services
tinybird-login:
build:
context: ./tinybird
dockerfile: Dockerfile
working_dir: /home/tinybird
command: /usr/local/bin/tinybird-login
volumes:
- tinybird_home:/home/tinybird
- tinybird_files:/data/tinybird
profiles: [analytics]
networks:
- ghost_network
tty: false
restart: no
tinybird-sync:
# Do not alter this without updating the Ghost container as well
image: ghost:${GHOST_VERSION:-6-alpine}
command: >
sh -c "
if [ -d /var/lib/ghost/current/core/server/data/tinybird ]; then
rm -rf /data/tinybird/*;
cp -rf /var/lib/ghost/current/core/server/data/tinybird/* /data/tinybird/;
echo 'Tinybird files synced into shared volume.';
else
echo 'Tinybird source directory not found.';
fi
"
volumes:
- tinybird_files:/data/tinybird
depends_on:
tinybird-login:
condition: service_completed_successfully
networks:
- ghost_network
profiles: [analytics]
restart: no
tinybird-deploy:
build:
context: ./tinybird
dockerfile: Dockerfile
working_dir: /data/tinybird
command: >
sh -c "
tb-wrapper --cloud deploy
"
volumes:
- tinybird_home:/home/tinybird
- tinybird_files:/data/tinybird
depends_on:
tinybird-sync:
condition: service_completed_successfully
profiles: [analytics]
networks:
- ghost_network
tty: true
activitypub-migrate:
image: ghcr.io/tryghost/activitypub-migrations:1.1.0@sha256:b3ab20f55d66eb79090130ff91b57fe93f8a4254b446c2c7fa4507535f503662
environment:
MYSQL_DB: mysql://${DATABASE_USER:-ghost}:${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub
networks:
- ghost_network
depends_on:
db:
condition: service_healthy
profiles: [activitypub]
restart: no
volumes:
caddy_data:
caddy_config:
tinybird_files:
tinybird_home:
traffic_analytics_data:
networks:
ghost_network:
d. My complete Caddy config:
https://{$DOMAIN}:443 {
import snippets/Logging
# Traffic Analytics service
import snippets/TrafficAnalytics
# ActivityPub Service
import snippets/ActivityPub
# Default proxy everything else to Ghost
handle {
reverse_proxy ghost:2368
}
# Optional: Enable gzip compression
encode gzip
# Optional: Add security headers
import snippets/SecurityHeaders
}
# Separate admin domains
# To use a separate domain for Ghost Admin uncomment the block below (recommended)
# {$ADMIN_DOMAIN} {
# import snippets/Logging
#
# # Traffic Analytics service
# import snippets/TrafficAnalytics
#
# # ActivityPub Service
# import snippets/ActivityPub
#
# # Default proxy everything else to Ghost
# handle {
# reverse_proxy ghost:2368
# }
#
# # Optional: Enable gzip compression
# encode gzip
#
# # Optional: Add security headers
# import snippets/SecurityHeaders
# }
# Redirect www -> root domain
# To redirect the www variant of your domain to the non-www variant uncomment the 4 lines below
# Note: You must have DNS setup correctly for both domains for this to work
# www.{$DOMAIN} {
# import snippets/Logging
# redir https://{$DOMAIN}{uri}
# }
# Redirect root -> www domain
# To redirect the non-www variant of your domain to the www variant uncomment the 4 lines below and change CHANGE_ME to your root domain
# Note: You must have DNS setup correctly for both domains for this to work
# When using ActivityPub with a www. domain, you must enable this redirect for ActivityPub to work correctly
# CHANGE_ME {
# import snippets/Logging
# redir https://{$DOMAIN}{uri}
# }