ActivityPub at Ghost in Docker + Traefik and Cloudflare

Hi everybody,

I’m running Ghost in Docker with Traefik and Cloudflare in front.

I tried to route all /.ghost/activitypub/* and .well-known/* requests through to ap.ghost.org, as that’s what Ghost Pro seems to use.

But all I get back is 403 Forbidden with messages like ROLE_MISSING or SITE_MISSING.

So before digging further:

:backhand_index_pointing_right: Is ActivityPub actually supposed to work on self-hosted Ghost at this point, or is it limited to Ghost(Pro)/Cloud only?

Thanks in advance!

1 Like

It definitely works for self-hosted Ghost. :slight_smile: I’m doing it. (Although with ghost-cli and NginX and cloudflare, not Docker & Traefik.)

Not all .well-known traffic goes to ap. ghost.org - you may want to look at the official ghost-cli or docker repo for working examples that could be translated to traefik.

1 Like

Well got it working.Solution was to split some stuff and set traefik Labels within the docker-compose. This was the key in the end. Not sure why but rules were not working within treafik.yml so I put them in the compose file.

within traefik folder I set up a ruleset for activitypub to keep traefik lean.

dynamic/activitypub.yml

http:
 services:
   ghost-activitypub:
     loadBalancer:
       passHostHeader: 
       trueserversTransport: ap-transport
       servers:- url: “https://ap.ghost.org”

 serversTransports:ap-transport:
 serverName: “ap.ghost.org”

within the docker-compose.yml as labels for ghost: (just change “yourdomain.com”)

    labels:
      - "traefik.enable=true"
      - "traefik.http.services.ghost.loadbalancer.server.port=2368"

      # Apex: https://yourdomain.com
      - "traefik.http.routers.ghost.rule=Host(`yourdomain.com`) && !PathPrefix(`/ghost`)"
      - "traefik.http.routers.ghost.entrypoints=websecure"
      - "traefik.http.routers.ghost.tls.certresolver=le"
      - "traefik.http.routers.ghost-admin.rule=Host(`yourdomain.com`) && PathPrefix(`/ghost`)"
      - "traefik.http.routers.ghost-admin.entrypoints=websecure"
      - "traefik.http.routers.ghost-admin.tls.certresolver=le"
      - "traefik.http.routers.ghost-admin.service=ghost"  

      # Admin API without Auth-Header
      - "traefik.http.routers.ghost-admin-api.rule=Host(`yourdomain.com`) && PathPrefix(`/ghost/api`)"
      - "traefik.http.routers.ghost-admin-api.entrypoints=websecure"
      - "traefik.http.routers.ghost-admin-api.tls.certresolver=le"
      - "traefik.http.routers.ghost-admin-api.service=ghost"
      - "traefik.http.routers.ghost-admin-api.middlewares=strip-auth"

      # Admin Assets
      - "traefik.http.routers.ghost-admin-assets.rule=Host(`yourdomain.com`) && PathPrefix(`/ghost/assets`)"
      - "traefik.http.routers.ghost-admin-assets.entrypoints=websecure"
      - "traefik.http.routers.ghost-admin-assets.tls.certresolver=le"
      - "traefik.http.routers.ghost-admin-assets.service=ghost"

      # Middleware: strip Authorization-Header (for Admin API)
      - "traefik.http.middlewares.strip-auth.headers.customrequestheaders.Authorization="

     # --- ActivityPub Router -> ap.ghost.org (service in file) ---
      - "traefik.http.routers.ghost-activitypub.rule=Host(`yourdomain.com`) && (PathPrefix(`/.ghost/activitypub/`) || Path(`/.well-known/webfinger`) || Path(`/.well-known/nodeinfo`) || Path(`/.well-known/host-meta`))"
      - "traefik.http.routers.ghost-activitypub.entrypoints=websecure"
      - "traefik.http.routers.ghost-activitypub.tls=true"
      - "traefik.http.routers.ghost-activitypub.tls.certresolver=le"
      - "traefik.http.routers.ghost-activitypub.priority=500"
      - "traefik.http.routers.ghost-activitypub.service=ghost-activitypub@file"

And finally Cloudflare:

  • WAF/Cache‑Bypass for:

  • /.ghost/activitypub/*, /.well-known/webfinger, /.well-known/host-meta, /.well-known/nodeinfo

  • SSL/TLS: Full (strict), no page‑rules/redirects/rocket‑loader for those paths.

Not sure if everything is necessary, but it’s working for me. Hope there is some use of it for somebody else.

1 Like

Hi!
I’ve managed to create a solution for my Kubernetes implementation of Ghost. I’m using a deployment based on GitHub - sredevopsorg/ghost-on-kubernetes: Ghost on Kubernetes by SREDevOps.org - Deploy Ghost v6 on Kubernetes (k8s, k3s, etc) with our hardened distroless rootless custom image. and Traefik v3 Helm Chart.
The working configuration (for my scenario, it could be different on your specific Kubernetes cluster):

# Optional: If you want to enable ActivityPub Service using Traefik v3 HelmChart
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  annotations:
    kubernetes.io/ingress.class: traefik
  name: ghost-on-kubernetes-ingressroute
  namespace: ghost-on-kubernetes
spec:
  entryPoints:
  - websecure
  routes:
  - kind: Rule
    match: Host(`yourdomain.tld`) && (PathPrefix(`/.ghost/activitypub/`) || Path(`/.well-known/webfinger`) || Path(`/.well-known/nodeinfo`) )
    services:
    - kind: TraefikService
      name: ghost-activitypub@file
      serversTransport: ap-transport@file

Traefik values:

# Traefik v3 Helm Chart values needed for this implementation
# More info on https://github.com/traefik/traefik-helm-chart
# Important note: You might need to manually install Traefik's CRD, please refer to their docs for more info.
providers:
  file:
    enabled: true
    watch: true
    content: |
      http:
        services:
          ghost-activitypub:
            loadBalancer:
              passHostHeader: true
              serversTransport: ap-transport
              servers:
                - url: "https://ap.ghost.org"
        serversTransports:
          ap-transport:
            serverName: "ap.ghost.org"
            insecureSkipVerify: true
            disableHTTP2: true
            maxIdleConnsPerHost: 1
            forwardingTimeouts:
              dialTimeout: 42s
              responseHeaderTimeout: 42s
              idleConnTimeout: 42s
  kubernetesCRD:
    # -- Load Kubernetes IngressRoute provider
    enabled: true
    # -- Allows IngressRoute to reference resources in namespace other than theirs
    allowCrossNamespace: true
    # -- Allows to reference ExternalName services in IngressRoute
    allowExternalNameServices: true
    # -- Allows to return 503 when there are no endpoints available
    allowEmptyServices: true
    # -- When the parameter is set, only resources containing an annotation with the same value are processed. Otherwise, resources missing the annotation, having an empty value, or the value traefik are processed. It will also set required annotation on Dashboard and Healthcheck IngressRoute when enabled.
    ingressClass: ""
    # -- See [upstream documentation](https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-ingress/#opt-providers-kubernetesIngress-labelselector)
    labelSelector: ""
    # -- Array of namespaces to watch. If left empty, Traefik watches all namespaces. . When using `rbac.namespaced`, it will watch helm release namespace and namespaces listed in this array.
    namespaces: []
    # -- Defines whether to use Native Kubernetes load-balancing mode by default.
    nativeLBByDefault: false
  
  kubernetesIngress:
    # -- Load Kubernetes Ingress provider
    enabled: true
    # -- Allows to reference ExternalName services in Ingress
    allowExternalNameServices: true
    # -- Allows to return 503 when there are no endpoints available
    allowEmptyServices: true
    # -- Only for Traefik v3.0, Deprecated since v3.1. See [upstream documentation](https://doc.traefik.io/traefik/v3.0/providers/kubernetes-ingress/#disableingressclasslookup)
    disableIngressClassLookup: false
    # -- When ingressClass is set, only Ingresses containing an annotation with the same value are processed. Otherwise, Ingresses missing the annotation, having an empty value, or the value traefik are processed.
    ingressClass:  # @schema type:[string, null]
      traefik
    labelSelector:  # @schema type:[string, null]
    # -- Array of namespaces to watch. If left empty, Traefik watches all namespaces. . When using `rbac.namespaced`, it will watch helm release namespace and namespaces listed in this array.
    namespaces: []
    # IP used for Kubernetes Ingress endpoints
    publishedService:
      # -- Enable [publishedService](https://doc.traefik.io/traefik/providers/kubernetes-ingress/#publishedservice),
      # usually with the Service provided by this Chart. It's possible to use it with an external Service using pathOverride.
      enabled: true
      # -- Override path of Kubernetes Service used to copy status from. Format: namespace/servicename.
      # Default to Service deployed with this Chart.
      pathOverride: ""
    # -- Defines whether to use Native Kubernetes load-balancing mode by default.
    nativeLBByDefault: false
    # -- Defines whether to make prefix matching strictly comply with the Kubernetes Ingress specification.
    strictPrefixMatching: false