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.

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.

Step 1 – Install Dokploy
Installing Dokploy is straightforward. The official docs explain it well.
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.
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:
Now, here’s the tricky bit:
cd
intoactivitypub/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:
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:
- Create a new Plausible template app in Dokploy.
- 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:
- Build the ActivityPub image (if ARM64).
- Launch Ghost + MySQL + ActivityPub.
- 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