How to Configure Caddy with Cloudflare Tunnel and Ghost CMS in Docker Setup?

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}
# }

5. Links to relevant resources:

I forgot to mention that I can’t access the Ghost instance from within my network.

Below is what happens when I try to access it via curl:

$ curl -kv http://10.10.10.25
*   Trying 10.10.10.25:80...
* Connected to 10.10.10.25 (10.10.10.25) port 80 (#0)
* Using HTTP/1.x
> GET / HTTP/1.1
> Host: 10.10.10.25
> User-Agent: curl/8.13.0
> Accept: */*
>
* Request completely sent
< HTTP/1.1 308 Permanent Redirect
< Connection: close
< Location: https://10.10.10.25/
< Server: Caddy
< Date: Sun, 01 Feb 2026 00:55:33 GMT
< Content-Length: 0
<
* Closing connection 0

The HTTP request successfully redirects me to HTTPS.

However, when I try to connect to HTTPS, I get a TLS error:

$ curl -kv https://10.10.10.25
*   Trying 10.10.10.25:443...
* ALPN: offering h2, http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS alert, internal error (592):
* TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal error
* Closing connection 0
curl: (35) TLS connect error: error:0A000438:SSL routines::tlsv1 alert internal error