How I Got ActivityPub To Run on Magic Pages

Jannis Fedoruk-Betschki
Jannis Fedoruk-Betschki
β€’
10 min read

How I jumped through the hurdles of getting ActivityPub running on Magic Pages - and why social media fatigue played a huge role.

πŸ‘‡
Want to skip to the technical details? Click here!

Around a year ago, the Ghost team announced the integration of ActivityPub. I remember reading the announcement and thinking "WOW".

As someone who really kickstarted his entrepreneurial journey on Twitter, social media has always been somewhat relevant for me. Not for creating content, but rather feeling "up to date". Facebook, Instagram, and Twitter have been part of my life since I was around 12 years old. Turning thirty at the end of this year, well...I wrote status updates, poked people, or watched stories for well over half my life.

And yet, there has always been that icky feeling. A feeling of not really knowing what's going on behind the scenes. A feeling of playing someone else's game. Looking at it soberly: A feeling of being the product – rather than using a product.

So, my WOW didn't come from nothing. It came after leaving Twitter behind, when things turned sour (pretty sure you all know what – or who – I mean). And it was risky as hell. I built a small following of around 1,300 people on Twitter – and this has been the main driving force of getting Magic Pages off the ground. And there I was in early 2023. A new business – and nowhere to talk about it.

The announcement of Ghost joining the ActivityPub network felt like a new start. Like I could finally be social again (well...online), while not selling my soul. I could own all of my content. I would be in charge.

And as the months went by, the excitment turned into worry. From Day 1, I casually checked the ActivityPub Github repository:

GitHub - TryGhost/ActivityPub: A full-featured ActivityPub server for networked publishing with Ghost
A full-featured ActivityPub server for networked publishing with Ghost - TryGhost/ActivityPub

It felt overwhelming. Instead of integrating INTO Ghost, it is technically infrastructure that lives NEXT to Ghost. If I ran a single Ghost site, this felt managable. But running Magic Pages, I wasn't sure how it would make sense, keeping in mind that financial constraints of running a hosting business.

More months went by. The Ghost team opened the beta on Ghost(Pro). And it was time to take another look.

Back in March 2025, after reading Cathy's article, I took a deeper look at the ActivityPub repository for the first time and documented my findings here:

Trying to get ActivityPub running as well
I just read Cathy’s blog post about getting ActivityPub started: Getting ActivityPub running... well, not yet.Spectral Web ServicesCathy Sarisky, Ghost Expert Since I want to get ActivityPub running on Magic Pages asap, this has been on my list forever – but well...other things always seem to pop up. Cathy

My conclusion back then: it works. But this is hella complicated. Ugh.

So, I put it aside again. More important things were happening at Magic Pages.

Fast-forward to May 2025. I was on a roadtrip from Wales back to Austria, spending some time in London, Paris, Milan, and Venice. The roadtrip was a trip together with my wife and our friend – and very first Magic Pages customer – Christine. And we shared lots of memories and impressions that I kinda wanted to share with the world.

ActivityPub popped back into my mind. (To be fair, I received countless questions from Magic Pages customers on when it would be available – so, you all really put it there πŸ˜…)

And then, after a long car ride from Paris to Milan, I needed some "me time". That usually meant...opening up a code editor. No bugs to fix, I downloaded a copy of the ActivityPub repository and started digging through files.


I want to preface this with the fact that my only goal was to make ActivityPub run on Magic Pages. The environment here is optimised for hosting hundreds of Ghost sites – not a single self-hosted one on a lonely VPS. Keep that in mind while you read through this. If you want to make ActivityPub with Ghost working on a VPS, there are easier ways.

The biggest hurdle of the ActivityPub repository is mindset, in my eyes. As I mentioned above, it is not integrated INTO Ghost, but is an entirely separate service NEXT TO Ghost. Something that we haven't seen in the world of Ghost yet (correct me, if I missed something).

So far, all features and functionalities were integrated into the Ghost monorepo. With a single command to run it all. Most features could be targeted with configuration files or environment variables. So, having a separate ActivityPub repository – and infrastructure – was the biggest mental hurdle I had to overcome.

But once I put that aside, things started to click fairly quickly.

Deciphering The Different Components

The ActivityPub infrastructure has one main component. The activitypub server (groudbreaking, I know). It is Ghost's adaption of Fedify – a Typescript library to build federated apps. The Ghost team went into a bit more details here:

Alright, let’s Fedify
Problem: It’s the end of a long week and your soul is tired from the endless monotony of slowly but surely conforming your behaviour to accommodate social acceptability as defined by our algorithmic overlords. Solution: Pull up a chair and engage in escapist optimism by reading about an equally disheveled

The activitypub server relies on a few bits and pieces, so it can do what it does: federate.

The Ghost team actually published a Docker Compose file, that gives an overview – this was my starting point:

ActivityPub/docker-compose.yml at main Β· TryGhost/ActivityPub
A full-featured ActivityPub server for networked publishing with Ghost - TryGhost/ActivityPub

(I am not going through the services from top to bottom, but in the order that I tackled when trying to make things work – so you can see where my brain went to).

Let's start with mysql. If you're a developer, you probably know what that is. Just a typical relational database that's been aroud for ages (in fact, it's older than me πŸ™ƒ). It is basically the data storage for the ActivityPub integration.

In the case of Magic Pages, I already have a big dedicated MySQL cluster that holds all the MySQL databases for all Ghost sites. It felt more natural, to add the ActivityPub MySQL database there, rather than creating a separate cluster, that needed to be managed. Felt cute, might change that later. (My wife will facepalm me hard for adding this in here. Hi πŸ‘‹)

Then, there is migrate. And this is where things get interesting. Since the ActivityPub integration is actively developed, things change. And when things change in a database (the underlying data storage), you don't want to dig in the database schemas manually. You want to define a migration and have it run when necessary.

The Ghost team created a migrate service in their Docker Compose file, which does exactly that. Before the ActivityPub server runs, it checks if there are any database migrations that were recently added. If there are, they are run first.

If you don't run these migrations when they are available, you will not be able to run more recent version of the software that relies on it.

So, migrate is absolutely necessary for production. However, since I manage the deployment of the ActivityPub server manually for now, I kept it out of the production stack and created a dedicated docker-compose.migrations.yaml, which I run manually on my computer, whenever new migrations are available.

Next up, all the complicated things. Woohoo!

No, seriously. So far, this has been pretty straightforward. You have a server. That server relies on a database. And the database has migrations. Easy peasy.

Then there are two oddities that caused some headache: pubsub and fake-gcs.

Let's start with pubsub. "Pub/Sub is an asynchronous and scalable messaging service that decouples services producing messages from services processing those messages." – or to turn that pretty bulky statement from Google's product page into something simpler:

It's a service that sends and receives messages. Basically, there are categories – or topics. And as a sender, I can publish messages/data into them. Another service – the subscriber – can well...subscripe to that topic and will receive them after I published them. Basically: I dump a bunch of things, and trust that whoever needs them fetches them.

In the ActivityPub server, pubsub is used to communicate about two "topics": fedify-topic, for the communication about federated events (e.g. somebody followed you, or liked a post), and ghost-topic, for stuff relating to the Ghost instance couped with a federated site.

The issue here: the ActivityPub server relies on the way Google built pubsub, which is basically a product of Google's Cloud Platform (GCP). While there is an emulator image (see Ghost's Docker Compose), I did not want to risk using this in a production setting and have opted to use GCP for pubsub.

The second part where the ActivityPub server relies on GCP is Google Cloud Storage (GCS). As far as I could tell, this is used to store images. Again, a local emulator is available, but I want something robust for production use. Therefore, I have setup a bucket on GCS.

One of the trickier parts was getting the right GCP permissions set up. I had to create a service account, assign it the Pub/Sub Publisher and Storage Object Admin roles, and mount the credentials into the ActivityPub container. Being new to GCP, this wasn't entirely clear from the beginning. Without this, the server couldn’t publish messages or store images. So, if you run into these issues as well, double-check that.

The reliance on GCP is the #1 thing I want to remove from this setup in the future. However, the Ghost team also built their beta for Ghost(Pro) on it, so for production use, there is little way around it for now, in my eyes.

Lastly, there is nginx. A classic web server. In the ActivityPub repository, it is used to proxy a Ghost site through it. And once you understand that, putting it all together becomes pretty simple:

server {
    client_max_body_size 25M;

    location /.ghost/activitypub {
        proxy_pass http://activitypub:8080;
    }

    location /.well-known/webfinger {
        proxy_pass http://activitypub:8080;
    }

    location /.well-known/nodeinfo {
        proxy_pass http://activitypub:8080;
    }

    location / {
        proxy_pass http://host.docker.internal:2368;
    }
}

This is the server.conf file from Ghost's ActivityPub repository, as of 20 May 2025. It basically passes all requests on /.ghost/activitypub, /.well-known/webfinger, and /well-known/nodeinfo to the ActivityPub server. All other requests, are passed to port 2368 – a Ghost site.

It's a bit weird, because it kinda turns things around. Usually, I would have a reverse proxy like this on Ghost's side. So, this is exactly what I did.

I took the existing Kubernetes ingress I already had setup for every Ghost site, and added the three routes for ActivityPub. Rather than being served by Ghost, these routes are being forwarded to the ActivityPub server, who then responds. Magic!

Running In Production

Now, you might have noticed. There are a lot of services that are present in the Docker Compose, but not in my list. That's mainly because they are development dependencies, test setups, etc. In production, I am running a pretty simple Docker Compose file:

version: '3.8'

services:
  activitypub:
    build: 
      context: ./vendors/activitypub
      dockerfile: Dockerfile
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - activitypub_data:/opt/activitypub/data
      - ./gcp.key.json:/opt/activitypub/gcp.key.json:ro
    environment:
      - PORT=8080
      - MYSQL_USER=user
      - MYSQL_PASSWORD=password
      - MYSQL_HOST=host
      - MYSQL_PORT=port
      - MYSQL_DATABASE=database
      - NODE_ENV=production
      - ALLOW_PRIVATE_ADDRESS=true
      - SKIP_SIGNATURE_VERIFICATION=false
      - USE_MQ=true
      - MQ_PUBSUB_PROJECT_ID=project_id
      - MQ_PUBSUB_HOST=host
      - MQ_PUBSUB_TOPIC_NAME=fedify-topic
      - MQ_PUBSUB_SUBSCRIPTION_NAME=fedify-subscription
      - MQ_PUBSUB_GHOST_TOPIC_NAME=ghost-topic
      - MQ_PUBSUB_GHOST_SUBSCRIPTION_NAME=ghost-subscription
      - GCP_BUCKET_NAME=bucket_name
      - ACTIVITYPUB_COLLECTION_PAGE_SIZE=50
      - GOOGLE_CLOUD_PROJECT=project_id
      - GOOGLE_APPLICATION_CREDENTIALS=/opt/activitypub/gcp.key.json
      - GHOST_PUSH_ENDPOINT=http://activitypub:8080/pubsub/ghost/push
      - FEDIFY_PUSH_ENDPOINT=http://activitypub:8080/.ghost/activitypub/mq

As mentioned, the MySQL database runs on a separate cluster (within the same private network on my infrastructure provider Hetzner). pubsub and gcs are served through GCP. And I have removed the nginx dependency in favour of handling this in my Kubernetes ingresses (sorry, no point in posting these, since these are really specific to the Magic Pages network - but they essentially do the same as the server.conf, just from the other side).

Scaling

The nice part about Ghost's ActivityPub server: it comes with multi-tenancy built in! No setup required.

Whenever a new site connects to the server, the server checks whether an entry in the sites table of the database exists. If it doesn't, it will add it. All requests are then scoped to that site.

When Ghost first started working on ActivityPub, my biggest concern was having to host the server for every single Ghost site – so, seeing how easy multi-tenancy was, was a huge relief.

So far, there are 41 sites running on Magic Pages. And it's going pretty well (read: "Jannis is having lots of fun interacting with his customers on ActivityPub") πŸŽ‰

Pitfalls Along The Way

Some pitfalls along the way (in no particular order):

  • If you're using a content delivery network, you want to add a cache exception for /.ghost/activitypub, /.well-known/webfinger, as well as /.well-known/nodeinfo. Otherwise well...you're going to cache whatever comes out of your social web platform (sounds nice for speed, but you always want to show the newest posts – not what has been cached a week before).
  • The ActivityPub server uses the Host header to distinguish between sites, so make sure your CDN or ingress forwards it correctly. Otherwise, requests might fail.
  • ActivityPub is decentralised by nature. This means, every instance out there needs to be notified of any post, like, profile update, etc. At the same time, every instance one of your users is subscribed to, will also tell you about changes (yes, simplified). This will create a LOT of requests.I hadn't considered that in the beginning and DoS'ed myself, when I opened up the ActivityPub beta to all Magic Pages customers. So uhh...make sure your network can properly handle the traffic. My issue wasn't necessarily with the network, but with setting the Kubernetes ingress up the wrong way, which led to a DNS lookup every time a request came in. Kubernetes DNS services then kept crashing over and over again, on the affected nodes πŸ™ƒ
  • When migrating a Ghost site that previously used ActivityPub on Ghost(Pro), the user reported 401 errors because the account already existed in the database – however, it did not create a local user (accounts and users are two separate things here). I ended up contributing a fix upstream to handle this edge case, but if you’re migrating, keep an eye out for existing ActivityPub accounts.
  • I'll add more here as things pop up...
Jannis Fedoruk-Betschki

About Jannis Fedoruk-Betschki

I'm the founder of Magic Pages, providing managed Ghost hosting that makes it easy to focus on your content instead of technical details.

You might also like

Customer Showcase

Websites powered by Magic Pages

See what real publishers have built with Ghost CMS and Magic Pages hosting.

Start Your 14-Day Free Trial

No credit card required β€’ Set up in minutes