Fixing Ghost ActivityPub 404 Error on Self-Hosted Installation

Problem

When trying to enable ActivityPub/Network features on a self-hosted Ghost instance (with or without Cloudflare), you may encounter:

  • The Network tab shows errors or fails to load
  • API endpoints return 403 errors with ROLE_MISSING or SITE_MISSING codes
  • Ghost logs show: ERROR No webhook secret found - cannot initialise
  • Critical issue: /ghost/.well-known/jwks.json returns 404

Summary

The key insight is that Ghost’s ActivityPub initialization requires reading a JWKS (JSON Web Key Set) endpoint from your local Ghost instance, not from ap.ghost.org. If nginx proxy configuration prevents this endpoint from being served, the ActivityPub handshake fails. By adding an exact-match nginx location block for /ghost/.well-known/jwks.json that proxies to your local Ghost, and then restarting Ghost to trigger re-initialization, the ActivityPub feature should work correctly.

This was tested and verified working on Ghost 6.3.0 with Ubuntu 22.04, nginx 1.18+, and Cloudflare CDN.

Solution

The fix involves ensuring nginx properly proxies the JWKS endpoint to your local Ghost instance instead of blocking it or proxying it to ap.ghost.org.

Step 1: Verify the Problem

Test the JWKS endpoint directly on your Ghost upstream (from the server):

curl -I http://127.0.0.1:2368/ghost/.well-known/jwks.json

Expected result: HTTP/1.1 200 OK

Now test via your public domain:

curl -I https://yourdomain.com/ghost/.well-known/jwks.json

If this returns 404, the nginx configuration needs adjustment.

Step 2: Add JWKS Proxy Configuration

Edit your nginx site configuration (typically /etc/nginx/sites-available/yourdomain.conf):

Before the general location ~ /.well-known block, add the location that matches the endpoint path exactly:

# Serve JWKS from local Ghost app (required for ActivityPub)
location = /ghost/.well-known/jwks.json {
    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;
}

Important placement notes:

  • This must come before any generic .well-known location blocks
  • It must come before the main location / proxy block
  • Use location = (exact match) to prevent conflicts with other paths

Step 3: Validate and Reload nginx

# Test configuration syntax
sudo nginx -t

# If successful, reload nginx
sudo systemctl reload nginx

Step 4: Verify JWKS Endpoint

Test the public endpoint again:

curl -sS https://yourdomain.com/ghost/.well-known/jwks.json

Expected output (keys will be different for your site):

{"keys":[{"e":"AQAB","kid":"xxx","kty":"RSA","n":"xxx","use":"sig"}]}

Step 5: Restart Ghost

For ActivityPub to initialize properly, Ghost needs to restart after the JWKS endpoint is accessible:

# If using systemd (most common)
sudo systemctl restart ghost_yoursitename

# Wait 2 minutes for Ghost to fully initialize

Step 6: Verify ActivityPub Endpoints

After Ghost restarts, test the ActivityPub endpoints:

# Should return 200 (may show data or require authentication)
curl -I https://yourdomain.com/.well-known/webfinger?resource=acct:index@yourdomain.com

# May still show 403 until you configure ActivityPub in admin panel
curl -I https://yourdomain.com/.ghost/activitypub/v1/site

Step 7: Enable Network Feature

  1. Log into your Ghost Admin panel
  2. Navigate to Settings → Labs
  3. Enable the Network toggle
  4. The Network tab should now load without errors

Common Pitfalls

1. Incorrect Location Block Order

nginx processes location blocks in a specific order. Exact matches (location =) take priority, but if you have overlapping regex matches, placement matters.

Wrong:

location ~ /.well-known {
    # Generic block catches everything first
}

location = /ghost/.well-known/jwks.json {
    # This never gets hit
}

Correct:

location = /ghost/.well-known/jwks.json {
    proxy_pass http://127.0.0.1:2368;
}

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

2. Proxying JWKS to ap.ghost.org

Some guides suggest proxying all ActivityPub paths to ap.ghost.org. The JWKS endpoint is an exception – it must be served from your local Ghost instance.

Wrong:

location ~ ^/ghost/.well-known {
    proxy_pass https://ap.ghost.org;  # This breaks JWKS
}

3. Not Waiting Long Enough After Restart

Ghost’s ActivityPub initialization can take 1-2 minutes. If you check immediately after restart, you might still see errors. Wait at least 2 minutes before testing.

4. Cached 404 Responses

If you tested the JWKS endpoint before fixing it, Cloudflare or your browser may have cached the 404. Use:

# Force fresh request
curl -H "Cache-Control: no-cache" https://yourdomain.com/ghost/.well-known/jwks.json

Verification Commands

To confirm everything is working:

# 1. JWKS should return 200 and JSON
curl -sS https://yourdomain.com/ghost/.well-known/jwks.json | jq .

# 2. Webfinger should return 200
curl -I https://yourdomain.com/.well-known/webfinger?resource=acct:index@yourdomain.com

# 3. Check Ghost logs for initialization (Ubuntu/systemd)
sudo journalctl -u ghost_yoursitename -n 50 --no-pager | grep -i activitypub

Additional Notes for Cloudflare Users

If you’re behind Cloudflare, ensure:

  1. SSL/TLS mode is set to “Full” or “Full (strict)” in Cloudflare dashboard
  2. Your origin server has valid SSL certificates
  3. Cloudflare proxy is enabled (orange cloud) for your domain

Still Having Issues?

If this doesn’t resolve your problem, check:

  1. Ghost logs: sudo journalctl -u ghost_yoursitename -f (look for ActivityPub errors)
  2. nginx error logs: /var/log/nginx/error.log
  3. Verify Ghost is actually proxied: The JWKS endpoint must reach Ghost on port 2368 (or your configured port)
  4. DNS resolution: Ensure ap.ghost.org is reachable from your server

Complete Working nginx Example

Here’s a minimal working configuration for the HTTPS server block:

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    # SSL configuration here...

    # CRITICAL: JWKS must be served from local Ghost
    location = /ghost/.well-known/jwks.json {
        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;
    }

    # ActivityPub endpoints proxy to ap.ghost.org
    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;
        proxy_ssl_server_name on;
        proxy_pass https://ap.ghost.org;
    }

    # Federation discovery endpoints
    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;
        proxy_ssl_server_name on;
        proxy_pass https://ap.ghost.org;
    }

    # Main Ghost proxy
    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;
    }

    # Allow other well-known endpoints
    location ~ /.well-known {
        allow all;
    }
}
1 Like