How I Self-Hosted Ghost with ActivityPub on a VPS Using Dokploy

A complete guide to hosting Ghost CMS with ActivityPub on your VPS using Dokploy. Includes Docker Compose setup, ARM64 fixes, MySQL init scripts, domain setup, and Plausible analytics.

How I Self-Hosted Ghost with ActivityPub on a VPS Using Dokploy

When I decided to start blogging seriously, I wanted a setup that gave me full control, was self-hosted, and could federate with the Fediverse (Mastodon, Pleroma, etc.) using ActivityPub.

After some 2 days of deployment and debugging errors, I figured out a clean way to host Ghost CMS + ActivityPub on my VPS with Dokploy. If you’ve ever struggled with messy Docker configs or didn’t know how to connect Ghost with ActivityPub, this guide will save you hours.

If you’re not using Dokploy, you can still follow Ghost’s official Docker guide to host Ghost — it works perfectly. The only catch is ActivityPub, which ships prebuilt binaries only for AMD64 CPUs. If your server runs on ARM64, you’ll need to rebuild ActivityPub manually (I’ll show you how).

Why Dokploy?

Dokploy is like a lightweight, self-hosted PaaS that makes deploying apps with Docker Compose painless. Instead of manually writing systemd services, reverse proxies, and Let’s Encrypt certificates, Dokploy automates that for you.

Dokploy - Effortless Deployment Solutions
Simplify your DevOps with Dokploy. Deploy applications and manage databases efficiently on any VPS.

Step 1 – Install Dokploy

Installing Dokploy is straightforward. The official docs explain it well.

Dokploy installation

Once installed, you’ll get a web dashboard to manage deployments.

Step 2 – Create a New Project in Dokploy

Inside the Dokploy dashboard, I created a new project and then a Compose Application.

This will give you a base directory where all your application files live, for example:

/etc/dokploy/compose/your-app-name

Step 3 – First Deployment

Do a first deployment (even with an empty Compose file). This step is important because Dokploy needs to set up the directories correctly before we add our custom config.

Step 4 – Setup MySQL Init Script

Ghost requires a MySQL database, but ActivityPub also needs its own schema. To handle this cleanly, I used a MySQL init script.

Inside your app directory (/etc/dokploy/compose/your-app-name), create a mysql-init folder and copy the entire content from the Ghost Docker mysql-init GitHub repo.

ghost-docker/mysql-init at main · TryGhost/ghost-docker
Contribute to TryGhost/ghost-docker development by creating an account on GitHub.

Step 5 – ActivityPub Build (if on ARM64)

If your VPS CPU is AMD64, you can skip this step.

But if you’re like me and using ARM64, you’ll need to rebuild the ActivityPub image from source. Clone the repo into your project directory:

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

Now, here’s the tricky bit:

  • cd into activitypub/migrate
  • Open the Dockerfile (e.g. nano Dockerfile)
  • Change this line:
curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-amd64.tar.gz | tar xvz

To this (for ARM64):

curl -L https://github.com/golang-migrate/migrate/releases/download/v4.17.1/migrate.linux-arm64.tar.gz | tar xvz

You can double-check your CPU architecture here:

GitHub - golang-migrate/migrate: Database migrations. CLI and Golang library.
Database migrations. CLI and Golang library. Contribute to golang-migrate/migrate development by creating an account on GitHub.

Step 6 – Set Environment Variables in Dokploy

Before running the compose file, add these environment variables in Dokploy’s App Settings → Environment Variables:


DOMAIN=your-domain-name
DATABASE_USER=your-db-user
DATABASE_PASSWORD=your-db-pass
DATABASE_ROOT_PASSWORD=your-db-root-pass
ACTIVITYPUB_TARGET=http://activitypub:8080
COMPOSE_PROFILES=activitypub

MAIL_TRANSPORT=smtp
MAIL_HOST=smtp.your-provider.com
MAIL_PORT=465
MAIL_SECURE=true
MAIL_USER=your-mail-user
MAIL_PASSWORD=your-mail-pass
MAIL_FROM=your-blog-name <[email protected]>

These cover:

  • Ghost + ActivityPub DB settings
  • Domain config
  • ActivityPub target
  • SMTP mail settings (so Ghost can send emails for signup & notifications)

Step 7 – Docker Compose Setup

Now for the fun part: the docker-compose.yml.

Here’s the full config I used, including:

  • Ghost CMS with Traefik routing + security headers
  • MySQL with multi-DB init (Ghost + ActivityPub)
  • ActivityPub service with proper routing (/.ghost/activitypub, WebFinger, NodeInfo)
  • Migration service to bootstrap ActivityPub’s schema

services:

  ghost:
    image: ghost:6-alpine
    restart: always
    environment:
      NODE_ENV: production
      url: https://${DOMAIN}
      database__client: mysql
      database__connection__host: db
      database__connection__user: ${DATABASE_USER}
      database__connection__password: ${DATABASE_PASSWORD}
      database__connection__database: ghost
      mail__transport: ${MAIL_TRANSPORT}
      mail__options__host: ${MAIL_HOST}
      mail__options__port: ${MAIL_PORT}
      mail__options__secure: ${MAIL_SECURE}
      mail__options__auth__user: ${MAIL_USER}
      mail__options__auth__pass: ${MAIL_PASSWORD}
      mail__from: ${MAIL_FROM}
      ACTIVITYPUB_TARGET: ${ACTIVITYPUB_TARGET}
      COMPOSE_PROFILES: ${COMPOSE_PROFILES}
    volumes:
      - ./data/ghost:/var/lib/ghost/content
    depends_on:
      db:
        condition: service_healthy
      activitypub:
        condition: service_started
        required: true
    networks:
      - dokploy-network
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.ghost.rule=Host(`${DOMAIN}`)"
      - "traefik.http.routers.ghost.entrypoints=websecure"
      - "traefik.http.routers.ghost.tls.certresolver=letsencrypt"
      - "traefik.http.services.ghost.loadbalancer.server.port=2368"

      # Security Headers
      - "traefik.http.middlewares.security-headers.headers.customrequestheaders.Strict-Transport-Security=max-age=31536000"
      - "traefik.http.middlewares.security-headers.headers.customrequestheaders.X-XSS-Protection=1; mode=block"
      - "traefik.http.middlewares.security-headers.headers.customrequestheaders.X-Content-Type-Options=nosniff"
      - "traefik.http.middlewares.security-headers.headers.customrequestheaders.Referrer-Policy=strict-origin-when-cross-origin"

      # Compression
      - "traefik.http.middlewares.gzip.compress=true"

      # Apply middlewares
      - "traefik.http.routers.ghost.middlewares=security-headers,gzip"

  db:
    image: mysql:8.0.42@sha256:4445b2668d41143cb50e471ee207f8822006249b6859b24f7e12479684def5d9
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD}
      MYSQL_USER: ${DATABASE_USER}
      MYSQL_PASSWORD: ${DATABASE_PASSWORD}
      MYSQL_DATABASE: ghost
      MYSQL_MULTIPLE_DATABASES: activitypub
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ../mysql-init:/docker-entrypoint-initdb.d
    healthcheck:
      test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1
      interval: 1s
      start_period: 30s
      start_interval: 10s
      retries: 120
    networks:
      - dokploy-network

  activitypub:
    build:
      context: ../activitypub
    restart: always
    volumes:
      - ./data/ghost:/opt/activitypub/content
    environment:
      NODE_ENV: production
      PORT: 8080
      MYSQL_HOST: db
      MYSQL_USER: ${DATABASE_USER}
      MYSQL_PASSWORD: ${DATABASE_PASSWORD}
      MYSQL_DATABASE: activitypub
      ALLOW_PRIVATE_ADDRESS: true
      USE_MQ: false
      LOCAL_STORAGE_PATH: /opt/activitypub/content/images/activitypub
      LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub
    depends_on:
      db:
        condition: service_healthy
      activitypub-migrate:
        condition: service_completed_successfully
    profiles: [activitypub]
    networks:
      - dokploy-network
    labels:
      - "traefik.enable=true"

      # ActivityPub endpoints
      - "traefik.http.routers.activitypub.rule=Host(`${DOMAIN}`) && PathPrefix(`/.ghost/activitypub`)"
      - "traefik.http.routers.activitypub.entrypoints=websecure"
      - "traefik.http.routers.activitypub.tls.certresolver=letsencrypt"
      - "traefik.http.routers.activitypub.priority=100"

      # WebFinger endpoint
      - "traefik.http.routers.webfinger.rule=Host(`${DOMAIN}`) && Path(`/.well-known/webfinger`)"
      - "traefik.http.routers.webfinger.entrypoints=websecure"
      - "traefik.http.routers.webfinger.tls.certresolver=letsencrypt"
      - "traefik.http.routers.webfinger.priority=100"

      # NodeInfo endpoint
      - "traefik.http.routers.nodeinfo.rule=Host(`${DOMAIN}`) && Path(`/.well-known/nodeinfo`)"
      - "traefik.http.routers.nodeinfo.entrypoints=websecure"
      - "traefik.http.routers.nodeinfo.tls.certresolver=letsencrypt"
      - "traefik.http.routers.nodeinfo.priority=100"

      - "traefik.http.services.activitypub.loadbalancer.server.port=8080"
      - "traefik.http.routers.activitypub.middlewares=security-headers"
      - "traefik.http.routers.webfinger.middlewares=security-headers"
      - "traefik.http.routers.nodeinfo.middlewares=security-headers"


  activitypub-migrate:
    build:
      context: ../activitypub/migrate
    environment:
      MYSQL_DB: mysql://${DATABASE_USER}:${DATABASE_PASSWORD}@tcp(db:3306)/activitypub
    depends_on:
      db:
        condition: service_healthy
    profiles: [activitypub]
    restart: no
    networks:
      - dokploy-network
    command: >
      sh -c "sleep 5 && migrate -path /migrations -database $${MYSQL_DB} up"
networks:
  dokploy-network:
    external: true
    

Step 8 – Analytics (Optional but Recommended)

I didn’t use Tinybird for analytics — I went with Plausible (self-hosted).

With Dokploy, it’s super simple:

  1. Create a new Plausible template app in Dokploy.
  2. Follow Ghost’s integration guide.

👉 Ghost + Plausible Integration Guide

This way, I get privacy-friendly analytics without leaking my data.


Key Things to Note (Not Obvious but Important 🚨)

  • Domain setup: Your domain must point to your VPS IP address before starting.
  • Database init: Copy the entire mysql-init repo so you stay future-proof.
  • ARM64 builds: If you’re on ARM64 (common for new VPS providers), you must rebuild ActivityPub and tweak the migrate Dockerfile.
  • Traefik labels: They don’t just route — here, they add security headers and gzip compression, which are often forgotten but boost performance + SEO.
  • ActivityPub endpoints: Ghost doesn’t need a second domain; ActivityPub runs behind Ghost at paths like /.ghost/activitypub.

Step 9 – Deploy from Dokploy

Now just hit Deploy from Dokploy. It will:

  1. Build the ActivityPub image (if ARM64).
  2. Launch Ghost + MySQL + ActivityPub.
  3. Automatically configure SSL via Let’s Encrypt.

After a successful deploy, you should be able to access your blog at:

👉 https://your-domain.com

And your Fediverse endpoints at:

  • https://your-domain.com/.well-known/webfinger
  • https://your-domain.com/.well-known/nodeinfo
  • https://your-domain.com/.ghost/activitypub

Conclusion

That’s it — Ghost + ActivityPub running on a VPS with Dokploy!

This stack gives you:
✅ A fast Ghost blog with full control
Federation support to connect with the Fediverse
✅ Automated SSL, routing, and headers with Traefik
Plausible analytics for privacy-friendly tracking
✅ A setup you can redeploy or migrate easily