ActivityPub with Apache as the reverse proxy

Hi all!

I’m looking for some help configuring Apache as a reverse proxy for ap.ghost.org

I have Ghost installed as a Docker image with Apache as the reverse proxy (for legacy reasons).

Here’s my Apache config:

<IfModule mod_ssl.c>
<VirtualHost *:443>
    ServerName gardinerbryant.com
    ServerAlias www.gardinerbryant.com

    Alias "/static" "/srv/ghost.gardinerbryant.com/static/"

    ProxyRequests On

    <Directory "/srv/ghost.gardinerbryant.com/static">
        Require all granted
    </Directory>

    ProxyPreserveHost On

    ## Static content
    ProxyPass "/static" !

    SSLProxyEngine On
    ProxyPreserveHost Off
    SSLProxyVerify none
    SSLProxyCheckPeerCN Off
    SSLProxyCheckPeerName Off
    SSLProxyCheckPeerExpire Off


    <Proxy "https://ap.ghost.org:443">

        RequestHeader set Host "ap.ghost.org"
        RequestHeader set "X-Forwarded-Host" "expr=%{SERVER_NAME}"
        RequestHeader set "X-Forwarded-Proto" "https"
        RequestHeader set "X-Forwarded-Port"  443
        RequestHeader set "X-Forwarded-For" "expr=%{REMOTE_ADDR}"
        RequestHeader set "X-Real-IP" "expr=%{REMOTE_ADDR}"

        RequestHeader set "Authorization" "expr=%{HTTP:Authorization}"
        ProxyPassReverseCookieDomain "ap.ghost.org" "gardinerbryant.com"

        Header set "Cache-Control" "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0"
        Header set "Surrogate-Control" "no-transform, no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0"
    </Proxy>


    ProxyPass "/.ghost/activitypub" https://ap.ghost.org:443/.ghost/activitypub
    ProxyPassReverse "/.ghost/activitypub" https://ap.ghost.org:443/.ghost/activitypub
    ProxyPass "/.well-known/webfinger" https://ap.ghost.org:443/.well-known/webfinger
    ProxyPassReverse "/.well-known/webfinger" https://ap.ghost.org:443/.well-known/webfinger
    ProxyPass "/.well-known/nodeinfo" https://ap.ghost.org:443/.well-known/nodeinfo
    ProxyPassReverse "/.well-known/nodeinfo" https://ap.ghost.org:443/.well-known/nodeinfo

    <Proxy "http://127.0.0.1:2368/"> # 2368
        RequestHeader set Host "${SERVER_NAME}"
        RequestHeader set "X-Forwarded-Host" "expr=%{SERVER_NAME}"
        RequestHeader set "X-Forwarded-Proto" "expr=%{REQUEST_SCHEME}"
        RequestHeader set "X-Forwarded-Port"  443
        RequestHeader set "X-Forwarded-For" "expr=%{REMOTE_ADDR}"
        RequestHeader set "Cache-Control" "no-store"

        RequestHeader set "X-Real-IP" "expr=%{REMOTE_ADDR}"
    </Proxy>

    ## Ghost Container
    ProxyPass "/" http://127.0.0.1:2368/
    ProxyPassReverse "/" http://127.0.0.1:2368/

    #RequestHeader set "X-Forwarded-SSL" expr=%{HTTPS}
    # Header set "Access-Control-Allow-Origin" "*"

    ErrorLog ${APACHE_LOG_DIR}/gardinerbryant.com_error.log
    CustomLog ${APACHE_LOG_DIR}/gardinerbryant.com_access.log combined

    RewriteEngine on

    # Some rewrite rules in this file were disabled on your HTTPS site,
    # because they have the potential to create redirection loops.
    RewriteCond %{SERVER_NAME} =www.gardinerbryant.com
    RewriteRule ^ https://gardinerbryant.com%{REQUEST_URI} [END,NE,R=temp]

    Include /etc/letsencrypt/options-ssl-apache.conf
    SSLCertificateFile /etc/letsencrypt/live/gardinerbryant.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/gardinerbryant.com/privkey.pem
</VirtualHost>
</IfModule>

When I try to access any of the proxied directories directly in my browser, I get this error:

{"error":"Forbidden","code":"SITE_MISSING"}

When I try accessing gardinerbryant.com/ghost/#/activitypub/reader, I see this as the response for the failed requests:

{"message":"Invalid URL"}

Finally, when I go to switch the Network tab off and then on again in the settings, I see this error in Ghost’s logs:

[2025-12-26 22:40:17] ERROR No webhook secret found - cannot initialise

When I searched for the above error message I found this post but it looks like this might be an issue on the remote server side?

I’m not 100% sure what my next steps should be. Any help would be appreciated!

Can you explain more the architecture you are looking to create?

https://gardinerbryant.com/

Appears to to be the domain you want to use for your Ghost instance– that’s what is serving now. This site is behind a reverse proxy provided by Cloudflare currently.

Meanwhile, ap.ghost.org is an ActivityPub not a Ghost instance.

Since the ActivityPub instance behind Ghost installs is usually not public-facing, why not access ap.ghost.org instead of trying to proxy traffic to through Apache?

I’m trying to activate Ghost’s ActivityPub features for my self-hosted instance. Every guide I’ve found has described exactly my setup but using Nginx instead of Apache.

I’m trying to do this:

<IfModule mod_ssl.c>
<VirtualHost *:443>
    ServerName gardinerbryant.com
    ServerAlias www.gardinerbryant.com

    # Security: Ensure this is Off (Reverse Proxy mode)
    ProxyRequests Off
    # We keep this Off so we can manually control the Host header for local vs remote
    ProxyPreserveHost Off 
    SSLProxyEngine On

    # Matches Nginx "proxy_ssl_server_name on" logic
    # This ensures Apache uses 'ap.ghost.org' for the SSL handshake SNI
    SSLProxyVerify none
    SSLProxyCheckPeerCN Off
    SSLProxyCheckPeerName Off
    SSLProxyCheckPeerExpire Off

    Alias "/static" "/srv/ghost.gardinerbryant.com/static/"
    <Directory "/srv/ghost.gardinerbryant.com/static">
        Require all granted
    </Directory>

    # Conditional Header Logic: Matches Nginx 'map' logic
    # Only set "nosniff" if the response status is NOT 204
    Header set X-Content-Type-Options "nosniff" "expr=%{REQUEST_STATUS} != 204"

    # 1. ACTIVITYPUB PROXY RULES (ap.ghost.org)
    <LocationMatch "^/(.well-known/(webfinger|nodeinfo)|.ghost/activitypub)">
        # Matches Nginx 'proxy_set_header Host $http_host'
        RequestHeader set Host "gardinerbryant.com"
        
        RequestHeader set X-Forwarded-Proto "https"
        RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"
        RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"

        ProxyPassMatch https://ap.ghost.org
        ProxyPassReverse https://ap.ghost.org
    </LocationMatch>

    # 2. LOCAL GHOST PROXY RULES
    <Location "/">
        ProxyPass "/static" !

        # Matches Nginx 'proxy_set_header Host $http_host'
        RequestHeader set Host "gardinerbryant.com"
        
        RequestHeader set X-Forwarded-Proto "https"
        RequestHeader set X-Real-IP "expr=%{REMOTE_ADDR}"
        RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"

        ProxyPass http://127.0.0.1:2368/
        ProxyPassReverse http://127.0.0.1:2368/
    </Location>

    # Logging
    ErrorLog ${APACHE_LOG_DIR}/gardinerbryant.com_error.log
    CustomLog ${APACHE_LOG_DIR}/gardinerbryant.com_access.log combined

    # Redirect WWW to Non-WWW
    RewriteEngine on
    RewriteCond %{HTTP_HOST} ^www\.gardinerbryant\.com [NC]
    RewriteRule ^(.*)$ https://gardinerbryant.com$1 [L,R=301]

    # Let's Encrypt Config
    Include /etc/letsencrypt/options-ssl-apache.conf
    SSLCertificateFile /etc/letsencrypt/live/gardinerbryant.com/fullchain.pem
    SSLCertificateKeyFile /etc/letsencrypt/live/gardinerbryant.com/privkey.pem
</VirtualHost>
</IfModule>

Restart and see if that works.

Thanks for the suggestions, KBExit!

I’ve tried them and here are the results.

I updated the config file with the one you provided but when I went to reload Apache it failed. ProxyPass “/static” ! cannot be inside the <Location “/"> block. (According to the error log the ProxyPass directive inside a Location block cannot have a location defined, even if it’s a negation)

So I moved the ProxyPass “/static” ! outside the block and it is able to reload. However, content in the static directory is now 404ing!

On the other hand. I tried turning off the ActivityPub feature and then turning it back on. When I do, I get this error in Ghost’s logs:

[2026-01-04 23:53:11] ERROR Could not get webhook secret for ActivityPub FetchError: invalid json response body at https://gardinerbryant.com/.ghost/activitypub/v1/site/ reason: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
[2026-01-04 23:53:11] ERROR No webhook secret found - cannot initialise

I was never seeing the JSON response body error before.

Curious, I visited https://gardinerbryant.com/.ghost/activitypub/v1/site/ and it’s now serving a standard Ghost 404 page. So the proxy defined in the <LocationMatch> block seems to not be working.

However, the reverse proxy defined in the <Location> block (meaning my Ghost site) is working just fine.

It should be noted that I also tried escaping the forward slashes in the regex <LocationMatch “^/(.well-known/(webfinger|nodeinfo)|.ghost/activitypub)”> and that still isn’t working for the ap.ghost.org proxy. I’ve also tried explicitly defining the port for the Proxy.

I’ve reverted my config back to the previously shared file.

I’m sorry it didn’t work. I don’t have a test environment for Apache.
Can I ask why you’re not using the proposed Caddy Method in the Docker Compose?

Also, now that I’ve reverted to my previous config, I visited https://gardinerbryant.com/.ghost/activitypub/v1/site/ again from my browser (by directly entering it in my address bar. I get this response:

{"error":"Forbidden","code":"ROLE_MISSING"}

This, to me, says that my proxy config is more or less correct.

Out of curiosity, I also tried turning off the ActivityPub setting within Ghost and then turning it back on. I got the following error:

[2026-01-05 00:12:12] INFO "GET /ghost/api/admin/integrations/?include=api_keys%2Cwebhooks&limit=50" 200 38ms
[2026-01-05 00:12:12] INFO "GET /ghost/api/admin/settings/?group=site%2Ctheme%2Cprivate%2Cmembers%2Cportal%2Cnewsletter%2Cemail%2Clabs%2Cslack%2Cunsplash%2Cviews%2Cfirstpromoter%2Ceditor%2Ccomments%2Canalytics%2Cannouncement%2Cpintura%2Cdonations%2Crecommendations%2Csecurity%2Csocial_web%2Cexplore" 200 18ms
[2026-01-05 00:12:12] ERROR No webhook secret found - cannot initialise

As you can see, there’s no error about invalid JSON.

My suspicion is that there’s either a misconfiguration in Ghost on my end. Is there some kind of JWT secret that needs to be defined in my config file?

As far as Caddy goes, I don’t know what that is.

EDIT: I checked the install guide and I guess when I read the info about Caddy I thought it was for running Ghost admin on a separate domain. I’ll try implementing caddy into my compose and get back to you.

That’s the official Reverse Proxy that Ghost 6 ships with. If you’re installing this other than Docker, you’re going to find yourself in a bind when 7 comes out and CLI is discontinued.