Setting Up Tinybird Analytics on a Self-Hosted Ghost Blog (Ghost-CLI, No Docker)

A hard-won guide to wiring Tinybird web analytics into a self-hosted Ghost blog installed via Ghost-CLI and served through a Cloudflare Tunnel, no nginx in front. The one-click setup that wasn't: SSH-tunneling the OAuth login and why the analytics toggle stays greyed out.

Setting Up Tinybird Analytics on a Self-Hosted Ghost Blog (Ghost-CLI, No Docker)

This guide documents the complete setup of Tinybird-powered web analytics on a self-hosted Ghost blog installed via Ghost-CLI on Ubuntu, served through a Cloudflare Tunnel (no nginx reverse proxy for Ghost traffic).

This document comes after I spent a long morning working on what should be a one-click-and-you're-ready configuration process. I remind myself though that Ghost is free. I have no room to complain. If my blog ever makes money, I'll find a way to contribute some of that back to their operation.

Environment:

  • Ghost 6.30.0 via Ghost-CLI 1.29.3
  • Node.js v22.21.0
  • Tinybird CLI (tb) v4.5.9
  • Ubuntu Linux (systemd)
  • Cloudflare Tunnel (remote-managed via Zero Trust dashboard)
  • Ghost site root: /var/www/example.com

Architecture Overview

Ghost's native web analytics has three components:

  1. Tinybird Cloud workspace — stores analytics events and exposes query endpoints that Ghost Admin reads to display charts and stats.
  2. TrafficAnalytics proxy — a Node.js service (by Ghost) that sits between the browser and Tinybird. It enriches raw page hits with parsed user agents, categorized referrers, and privacy-preserving visitor signatures before forwarding events to Tinybird's ingestion API.
  3. Ghost config — tells Ghost where the tracker endpoint lives and provides credentials for reading stats back from Tinybird.

The data flow:

Browser (ghost-stats.min.js)
  → POST https://analytics.example.com/api/v1/page_hit
  → Cloudflare Tunnel routes to localhost:3100
  → TrafficAnalytics proxy enriches the event
  → Proxy forwards to https://api.us-east.tinybird.co/v0/events
  → Tinybird stores in analytics_events datasource
  → Ghost Admin reads stats via Tinybird query endpoints (signed JWT)

Step 1: Create a Tinybird Account and Workspace

  1. Sign up at https://cloud.tinybird.co/signup
  2. Create a workspace. Choose a region — this guide uses us-east4 (GCP).
  3. Note your API host. For us-east4, it is: https://api.us-east.tinybird.co

Step 2: Install the Tinybird CLI

pip install tinybird-cli
# or
uv tool install tinybird-cli

Verify:

tb --version

Step 3: Authenticate the CLI

On a headless server, tb login won't work because the OAuth callback
redirects to localhost on the machine running the browser — not the server. To work around this, we have to use SSH port forwarding (insane, right?).

From your desktop/laptop, open a tunnel before running tb login:

ssh -L 49160:localhost:49160 username@<your-server>

Then on the server:

tb login

Select your region (e.g., 3 for us-east4). The CLI opens a browser URL. Since the SSH tunnel forwards port 49160, the OAuth callback reaches the CLI. After login completes, the CLI stores session info in ~/.tinybird/.

Step 4: Deploy Ghost's Tinybird Schema

Ghost ships Tinybird datasource, pipe, and endpoint definitions in its install. Deploy them to your cloud workspace.

Important: The tb CLI v4.x defaults to "local" mode (Docker). You must
pass --cloud, --host, and --token to target your cloud workspace.

cd /var/www/example.com/current/core/server/data/tinybird

tb --cloud \
   --host https://api.us-east.tinybird.co \
   --token '<your-admin-token>' \
   deploy

When prompted about running from an untracked folder, type y.

This creates all datasources (including analytics_events), materialized views, query endpoints, and tokens (tracker, stats_page, axis, monitoring).

Step 5: Retrieve Your Tinybird Credentials

tb --cloud \
   --host https://api.us-east.tinybird.co \
   --token '<your-admin-token>' \
   --show-tokens \
   token ls

Record these values:

Credential Where to find it
Workspace ID tb infoworkspace_id field
Admin Token The workspace admin token from token ls
Tracker Token The tracker token from token ls (has APPEND access to analytics_events)
API Host https://api.us-east.tinybird.co (your region — no .gcp in the URL)

Step 6: Install and Configure the TrafficAnalytics Proxy

Clone and build

cd /opt
sudo git clone https://github.com/TryGhost/TrafficAnalytics.git
sudo chown -R username:username TrafficAnalytics
cd TrafficAnalytics

# Enable yarn via corepack (ships with Node.js)
sudo corepack enable

yarn install
yarn build

Create the environment file

cat > /opt/TrafficAnalytics/.env << 'EOF'
TINYBIRD_TRACKER_TOKEN=<your-tracker-token>
PROXY_TARGET=https://api.us-east.tinybird.co/v0/events
PORT=3100
EOF

Replace <your-tracker-token> with the actual tracker token from Step 5.

Why port 3100? The default (3000) may conflict with other services. In our case, another service was already using port 3000.

Test it manually

PORT=3100 node dist/server.js

You should see log output ending with Server listening at http://...:3100. Press Ctrl-C after confirming.

Note: The .env file is NOT auto-loaded by Node.js. Running node dist/server.js without PORT=3100 will use the default port. The systemd service (below) usesEnvironmentFile to inject the .env values into the process environment.

Create a systemd service

sudo tee /etc/systemd/system/traffic-analytics.service > /dev/null << 'EOF'
[Unit]
Description=Ghost TrafficAnalytics Proxy
After=network.target

[Service]
Type=simple
User=cjones
WorkingDirectory=/opt/TrafficAnalytics
EnvironmentFile=/opt/TrafficAnalytics/.env
ExecStart=/usr/bin/node dist/server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable traffic-analytics
sudo systemctl start traffic-analytics

Verify:

sudo systemctl status traffic-analytics
curl -s http://127.0.0.1:3100/
# Should print: Hello Ghost Traffic Analytics

Step 7: Route Browser Traffic to the Proxy via Cloudflare Tunnel

The tracking script in the browser needs a publicly reachable URL to POST
analytics events. Since the blog is served through a Cloudflare Tunnel,
add a new subdomain route.

Why a subdomain instead of a path prefix? Cloudflare Tunnel does not strip matched path prefixes when forwarding to the origin. A path rule like example.com/.ghost/analytics would forward the full path
/.ghost/analytics/api/v1/page_hit to the proxy, which doesn't recognize it. A dedicated subdomain avoids this entirely.

In the Cloudflare Zero Trust dashboard (https://one.dash.cloudflare.com/):

  1. Go to NetworksTunnels → your tunnel → Public Hostname tab
  2. Click Add a public hostname:
    • Subdomain: analytics
    • Domain: example.com
    • Service type: HTTP
    • URL: localhost:3100
  3. Save

Verify the tunnel route is working:

curl -s https://analytics.example.com/
# Should print: Hello Ghost Traffic Analytics

Step 8: Configure Ghost

Edit config.production.json directly. Do not use ghost config set — it
writes flat colon-delimited keys (e.g., "tinybird:tracker:endpoint") instead of nested JSON objects. Ghost's nconf reads the JSON structure literally, so config.get('tinybird') returns undefined when the keys are flat strings.

Add the tinybird block to /var/www/example.com/config.production.json:

{
  "tinybird": {
    "workspaceId": "<your-workspace-id>",
    "adminToken": "<your-admin-token>",
    "tracker": {
      "endpoint": "https://analytics.example.com/api/v1/page_hit",
      "datasource": "analytics_events"
    },
    "stats": {
      "endpoint": "https://api.us-east.tinybird.co"
    }
  }
}

Then restart Ghost:

cd /var/www/example.com
ghost restart

Step 9: Enable Web Analytics in Ghost Admin

  1. Go to https://example.com/ghost/#/settings
  2. Find Analytics
  3. Toggle Web analytics on

If the toggle is greyed out / unavailable, Ghost isn't reading the tinybird
config correctly. Verify the JSON is nested (not flat colon-delimited keys)
and restart Ghost.

Step 10: Verify End-to-End

Check the tracking script is injected

curl -sL -H "Host: example.com" http://127.0.0.1:2368/ | grep ghost-stats

You should see a <script> tag with ghost-stats.min.js and
data-host="https://analytics.example.com/api/v1/page_hit".

Check in the browser

  1. Visit your site in a browser
  2. Open DevTools → Network tab
  3. Look for a POST request to analytics.example.com/api/v1/page_hit
  4. It should return 200 OK

Check Ghost Admin

Go to https://example.com/ghost/#/dashboard — the analytics section should begin populating with page view data.


Troubleshooting

tb deploy says "No container runtime is running"

You forgot --cloud. The CLI defaults to local Docker mode:

tb --cloud --host https://api.us-east.tinybird.co --token '<token>' deploy

tb deploy says "Workspace not found, make sure you use the token host"

You need --host https://api.us-east.tinybird.co. The CLI doesn't infer the host from the token.

Web analytics toggle is disabled in Ghost Admin

The tinybird config in config.production.json must be a nested JSON object, not flat colon-delimited keys. Check with:

python3 -c "import json; c=json.load(open('config.production.json')); print(json.dumps(c.get('tinybird'), indent=2))"

If it prints null, the config is wrong. Fix the JSON structure and restart Ghost.

Tracking script not appearing in page source

Verify web_analytics is enabled in the database:

mysql -u ghost -p ghost_prod -e "SELECT value FROM settings WHERE \`key\` = 'web_analytics';"

Should return true. If not, toggle it on in Ghost Admin → Settings → Analytics.

Browser shows 403 on the page_hit request

If using a subdomain through Cloudflare, check for WAF rules, Bot Fight Mode, or Cloudflare Access policies that might block cross-origin POST requests.

Browser shows "Invalid token"

The TINYBIRD_TRACKER_TOKEN in /opt/TrafficAnalytics/.env is missing or
wrong. Update it with the tracker token from tb token ls and restart:

sudo systemctl restart traffic-analytics

Port conflict on startup

Check what's using the port and pick a different one:

sudo lsof -i :3100

Update PORT= in /opt/TrafficAnalytics/.env and restart the service.


Maintenance Notes

After a Ghost update

Ghost updates may include new Tinybird schema migrations. Re-deploy from the new version's tinybird directory:

cd /var/www/example.com/current/core/server/data/tinybird
tb --cloud --host https://api.us-east.tinybird.co --token '<admin-token>' deploy

After a TrafficAnalytics update

cd /opt/TrafficAnalytics
git pull
yarn install
yarn build
sudo systemctl restart traffic-analytics

Key file locations

File Purpose
/var/www/example.com/config.production.json Ghost config (includes tinybird block)
/opt/TrafficAnalytics/.env Proxy config (tracker token, target, port)
/etc/systemd/system/traffic-analytics.service Systemd unit for the proxy
/var/www/example.com/current/core/server/data/tinybird/ Ghost's Tinybird schema files

Subscribe to A garage sale for your mind

Don’t miss out on the latest posts. Sign up now to get access to the library of members-only posts.
[email protected]
Subscribe