Ghost Blog v6 Breaking Changes: Fixing the ActivityPub Webhook Error

Ghost Blog v6 Breaking Changes: Fixing the ActivityPub Webhook Error
Photo by Jonny Gios / Unsplash

If you're staring at a broken Ghost blog after upgrading from 6.0.x to 6.5.x, you're not alone. What should have been a routine upgrade turned into a debugging session when my Ghost instance refused to start. Here's what went wrong and how to fix it.

The First Problem: Email OTP Login

Immediately after the upgrade, I couldn't log into the admin panel. The error message was clear but unhelpful:

"Failed to send email. Please check your site configuration and try again."

This was my first encounter with Ghost's device verification feature. When logging in from a new device or browser, Ghost sends an email-based One-Time Password (OTP) to verify it's really you. It's enabled by default, which means you need working email configuration.

Quick Fix Option 1 - Configure email sending properly (recommended for production): Follow the Ghost email configuration docs.

Quick Fix Option 2 - Disable the feature (good for testing/development): Add this to your Ghost config.production.json:

{
  "security": {
    "staffDeviceVerification": false
  }
}

I opted for Option 2 temporarily to get back online, updated my config, and restarted Ghost. That's when things got worse.

The Real Problem: ActivityPub Webhook Secret Error

After updating the config and attempting to restart Ghost, the service failed to start. My logs showed this error:

{"name":"Log","hostname":"xxx","pid":1096748,"level":50,"version":"6.5.3","msg":"Could not get webhook secret for ActivityPub FetchError: invalid json response body at https://xxx/.ghost/activitypub/v1/site reason: Unexpected token '<', \"<html>\r\n<h\"... is not valid JSON","time":"2025-10-28T05:59:06.764Z","v":0}
{"name":"Log","hostname":"xxx","pid":1096748,"level":50,"version":"6.5.3","msg":"No webhook secret found - cannot initialise","time":"2025-10-28T05:59:06.764Z","v":0}

The key detail: Ghost was expecting JSON but receiving HTML instead. This meant my nginx configuration was returning a 404 or default page instead of properly proxying the ActivityPub endpoints.

What is ActivityPub and Why Does Ghost Need It?

ActivityPub is the protocol behind the Fediverse (Mastodon, Pixelfed, etc.). Ghost 6.5.x added Fediverse integration, allowing your blog to federate with other ActivityPub-enabled platforms. To make this work, Ghost proxies certain endpoints through ap.ghost.org to handle the federation protocol.

The problem? Ghost CLI's upgrade process doesn't automatically update your nginx configuration to include these new proxy rules.

The Solution: Update Your Nginx Configuration

The fix requires two additions to your nginx configuration:

Step 1: Add the Content-Type Header Map

Add this before your server block (typically in the http block or at the top of your site config):

map $status $header_content_type_options {
    204 "";
    default "nosniff";
}

Step 2: Add ActivityPub Proxy Locations

Add these location blocks inside your server block:

# ActivityPub endpoints
location ~ /.ghost/activitypub/* {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host;
    add_header X-Content-Type-Options $header_content_type_options;
    proxy_ssl_server_name on;
    proxy_pass https://ap.ghost.org;
}

# WebFinger and NodeInfo for Fediverse discovery
location ~ /.well-known/(webfinger|nodeinfo) {
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header Host $http_host;
    add_header X-Content-Type-Options $header_content_type_options;
    proxy_ssl_server_name on;
    proxy_pass https://ap.ghost.org;
}

Step 3: Test and Reload

# Test nginx configuration
sudo nginx -t

# Reload nginx
sudo systemctl reload nginx

# Restart Ghost
ghost restart

Your Ghost instance should now start successfully.

Why This Happens

The root cause is a gap in Ghost CLI's upgrade automation. While Ghost maintains official nginx templates, the CLI doesn't automatically update your existing nginx configuration during upgrades.

Without these proxy rules:

  1. Ghost tries to fetch the ActivityPub webhook secret from /.ghost/activitypub/v1/site
  2. Nginx doesn't recognize the route and returns HTML (404 or default page)
  3. Ghost expects JSON, gets HTML, and refuses to start

Prevention for Future Upgrades

Here's what I learned:

  1. Check the official nginx templates after major Ghost upgrades
  2. Always test upgrades in staging before touching production
  3. Monitor logs during startup - errors appear immediately
  4. Keep backups of both Ghost data and configuration files

Self-hosted software requires vigilance. While Ghost CLI handles most upgrades smoothly, infrastructure changes like nginx configurations still need manual attention. This is the trade-off we accept when we choose self-hosting over managed solutions.

Complete Configuration Reference

For reference, here's what a complete nginx configuration section should look like after these changes:

ap $status $header_content_type_options {
    204 "";
    default "nosniff";
}

server {
    listen 80;
    listen [::]:80;

    server_name yourdomain.com;
    root /your/document/root; # Used for acme.sh SSL verification (https://acme.sh)

    location ~ /.ghost/activitypub/* {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        add_header X-Content-Type-Options $header_content_type_options;
        proxy_ssl_server_name on;
        proxy_pass https://ap.ghost.org;
    }

    location ~ /.well-known/(webfinger|nodeinfo) {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        add_header X-Content-Type-Options $header_content_type_options;
        proxy_ssl_server_name on;
        proxy_pass https://ap.ghost.org;
    }

    location / {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:2368;

        add_header X-Content-Type-Options $header_content_type_options;
    }

    location ~ ^/.well-known/acme-challenge {
        allow all;
    }

    client_max_body_size 50m;
}

If you hit this issue or have questions, feel free to reach out. The Ghost community is generally helpful, but documentation for breaking changes like this can lag behind releases.