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.
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:
- Tinybird Cloud workspace — stores analytics events and exposes query endpoints that Ghost Admin reads to display charts and stats.
- 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.
- 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
- Sign up at https://cloud.tinybird.co/signup
- Create a workspace. Choose a region — this guide uses us-east4 (GCP).
- 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 info → workspace_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/):
- Go to Networks → Tunnels → your tunnel → Public Hostname tab
- Click Add a public hostname:
- Subdomain:
analytics - Domain:
example.com - Service type: HTTP
- URL:
localhost:3100
- Subdomain:
- 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
- Go to
https://example.com/ghost/#/settings - Find Analytics
- 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 anddata-host="https://analytics.example.com/api/v1/page_hit".
Check in the browser
- Visit your site in a browser
- Open DevTools → Network tab
- Look for a POST request to
analytics.example.com/api/v1/page_hit - 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 |