Ghost recently released ActivityPub support, enabling Ghost blogs to participate in the Fediverse alongside Mastodon, Threads, and other federated platforms. Through extensive testing and production deployment, we discovered that the official ActivityPub container has a bug that prevents self-hosted ActivityPub deployments from working. Combined with AWS-specific networking challenges, we had to modify the container source code to achieve working federation.

Common Errors

Through the process of testing and building this solution, we encountered several errors that pointed us toward the underlying issues.

When we saw "The signer and the actor do not match," it indicated a URL mismatch between the HTTP signature and actor object. This led us to use Context methods exclusively for URL generation to ensure consistency.

The error "No webhook secret found - cannot initialise" was particularly misleading. It actually indicated a JWT authentication failure where ActivityPub was trying to fetch the JWKS endpoint via HTTP instead of HTTPS. Setting NODE_ENV=production forced the service to use HTTPS for all internal communications.

When actor endpoints contained HTTP URLs instead of HTTPS, we traced this to missing or incorrect X-Forwarded-Proto headers. The solution required ensuring nginx properly set X-Forwarded-Proto: https for all proxied requests.

The "No key pair found for actor 'index'" error revealed the need for dual-key support, requiring implementation of both Ed25519 and RSA key generation to support the full range of Fediverse servers.

Finally, 503 Service Unavailable errors typically indicated that the ActivityPub container wasn't healthy or had port misconfiguration issues. Checking the ALB target group health and ensuring port 8080 was accessible resolved these issues.

Resolution

Fortunately, we've been able to solve these problems and get Federation working with Ghost while deployed on AWS.

Issues when Deploying

After deploying to production and systematic testing, we discovered two separate issues that must both be fixed.

First, we encountered an infrastructure challenge specific to AWS. CloudFront sends CloudFront-Forwarded-Proto instead of the standard X-Forwarded-Proto header, causing the ActivityPub service to generate HTTP URLs instead of HTTPS. While this is solvable with nginx header translation, it took considerable debugging to identify.

Second, and more critically, the official container (ghcr.io/tryghost/activitypub:edge) has a bug where cryptographic keys are generated and stored but never attached to the Person object. This results in:

// Official container returns:
{
  "type": "Person",
  "publicKey": null, // Missing!
  "assertionMethod": null // Missing!
}

This missing key attachment causes complete federation failure. No Fediverse server can authenticate or interact with your Ghost instance, even if all infrastructure is perfectly configured.

The Problem

When deploying Ghost's ActivityPub on AWS, you can face both infrastructure and code challenges. On the infrastructure side, CloudFront sends non-standard headers that the ActivityPub service doesn't recognize, causing it to generate HTTP URLs instead of HTTPS. This breaks federation because remote servers expect secure connections.

The container itself has a more serious issue. While it successfully generates cryptographic keys for authentication, it fails to attach them to the Person object that represents your Ghost site in the Fediverse. Without these keys, no other server can verify your site's identity or accept its activities. Additionally, the original implementation only supports a single key type, limiting compatibility across the diverse Fediverse ecosystem where different platforms expect different signature methods.

We forked the repository, fixed these issues, and built a custom container. Our fixes are running in production at https://subaud.io with full federation capability.

Architecture Overview

graph TB
    subgraph Internet
        Users[Fediverse Users]
        Mastodon[Mastodon Servers]
    end

    subgraph AWS Cloud
        CF[CloudFront<br/>SSL Termination]
        ALB[Application Load Balancer<br/>HTTP internally]

        subgraph "ECS Fargate Task"
            Nginx[Nginx Proxy :80<br/>Header Translation]
            Ghost[Ghost CMS :2368]
            AP[ActivityPub :8080]

            Nginx -->|localhost| Ghost
            Nginx -->|localhost| AP
        end

        subgraph Storage
            RDS[(RDS MySQL)]
            EFS[EFS Volume]
            S3[S3 Images]
        end
    end

    Users --> CF
    Mastodon --> CF
    CF -->|CloudFront-Forwarded-Proto| ALB
    ALB -->|HTTP + Headers| Nginx

    Ghost --> RDS
    AP --> RDS
    Ghost --> EFS
    AP --> EFS
    Ghost --> S3

    style CF fill:#FFE5B4
    style ALB fill:#E6F3FF
    style Nginx fill:#D4EDDA
    style Ghost fill:#FFF3CD
    style AP fill:#F8D7DA

All containers run in a single ECS task, sharing the same network namespace for localhost communication. The nginx proxy translates CloudFront's non-standard headers to what ActivityPub expects.

Code Fixes

1. The Core Bug: Missing Key Attachment

The official container attempts to use publicKeys (plural) which doesn't work in Fedify:

// BROKEN - Official container code
const person = new Person({
  // ... other properties ...
  publicKeys: (await ctx.getActorKeyPairs(identifier)).map(
    (key) => key.cryptographicKey,
  ),
  // Result: publicKey and assertionMethod are NULL!
});

Our fix uses the correct Fedify properties:

// FIXED - What actually works
const keyPairs = await ctx.getActorKeyPairs(identifier);

const person = new Person({
  // ... other properties ...

  // CRITICAL: Use publicKey (singular) for HTTP Signatures
  publicKey: keyPairs[0]?.cryptographicKey,

  // CRITICAL: Add assertionMethod for Object Integrity Proofs
  assertionMethod: keyPairs.map((kp) => kp.multikey),
});

This single fix restores basic federation capability.

Here's the complete ActorDispatcher class implementation:

// src/dispatchers.ts - Complete ActorDispatcher class
export class ActorDispatcher {
    constructor(
        private readonly siteService: SiteService,
        private readonly accountService: AccountService,
    ) {}

    async dispatch(
        ctx: RequestContext<ContextData>,
        identifier: string,
    ) {
        const site = await this.siteService.getSiteByHost(ctx.host);
        if (site === null) return null;

        const account = await this.accountService.getDefaultAccountForSite(site);

        const keyPairs = await ctx.getActorKeyPairs(identifier);

        // Log key types for debugging
        ctx.data.logger.info('[KEY-ASSIGNMENT] Actor keyPairs:', {
            count: keyPairs.length,
            keys: keyPairs.map((kp, idx) => ({
                index: idx,
                hasMultikey: !!kp.multikey,
                hasCryptoKey: !!kp.cryptographicKey,
            }))
        });

        // Important: The order matters!
        // keyPairs[0] = Ed25519 (for Object Integrity Proofs) - #main-key
        // keyPairs[1] = RSA (for HTTP Signatures) - #key-2
        const assertionMethods = keyPairs.map((kp) => kp.multikey);

        ctx.data.logger.info('[KEY-ASSIGNMENT] Setting actor keys:', {
            publicKeyType: keyPairs[0]?.cryptographicKey ? 'First key (main-key)' : 'missing',
            assertionMethodCount: assertionMethods.length,
        });

        const person = new Person({
            id: ctx.getActorUri(identifier),
            name: account.name,
            summary: account.bio,
            preferredUsername: account.username,
            icon: account.avatar_url
                ? new Image({
                      url: new URL(account.avatar_url),
                  })
                : null,
            image: account.banner_image_url
                ? new Image({
                      url: new URL(account.banner_image_url),
                  })
                : null,
            inbox: ctx.getInboxUri(identifier),
            outbox: ctx.getOutboxUri(identifier),
            following: ctx.getFollowingUri(identifier),
            followers: ctx.getFollowersUri(identifier),
            liked: ctx.getLikedUri(identifier),
            url: new URL(account.url || account.ap_id),
            endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
            // Set assertionMethods for Object Integrity Proofs (includes both Ed25519 and RSA)
            assertionMethods: assertionMethods,
            // Set publicKey for HTTP Signatures - Use FIRST key as per Fedify convention
            publicKey: keyPairs[0]?.cryptographicKey,
            // REMOVED publicKeys - we set publicKey (singular) and assertionMethods instead
        });

        return person;
    }
}

### 2. Class-Based Dispatchers (Build Fix)

The original code used nested functions with identical names, causing bundler issues:

```javascript
// Original - broken after bundling
export const actorDispatcher = (siteService, accountService) =>
  async function actorDispatcher(ctx, identifier) {
    // Function renamed to actorDispatcher2 by bundler
  };

We refactored to class-based architecture:

// Fixed - class-based approach
export class ActorDispatcher {
  constructor(
    private readonly siteService: SiteService,
    private readonly accountService: AccountService,
  ) {}

  async dispatch(ctx: RequestContext<ContextData>, identifier: string) {
    // Clean implementation, no naming conflicts
  }
}

3. Dual-Key Cryptography Enhancement

ActivityPub requires two different signature methods. Mastodon uses RSA for HTTP signatures while newer implementations use Ed25519 for Object Integrity Proofs. The original code only supported one key type.

We need the KeypairDispatcher to load these keys from the database:

// src/dispatchers.ts - Complete KeypairDispatcher class
export class KeypairDispatcher {
    constructor(
        private readonly siteService: SiteService,
        private readonly accountService: AccountService,
    ) {}

    async dispatch(
        ctx: Context<ContextData>,
        identifier: string,
    ) {
        const site = await this.siteService.getSiteByHost(ctx.host);
        if (site === null) return [];

        const account = await this.accountService.getDefaultAccountForSite(site);

        if (!account.ap_public_key) {
            return [];
        }

        if (!account.ap_private_key) {
            return [];
        }

        try {
            const publicKeys = JSON.parse(account.ap_public_key);
            const privateKeys = JSON.parse(account.ap_private_key);

            // Handle new format with multiple keys
            if (publicKeys.keys && privateKeys.keys) {
                ctx.data.logger.info(`[DUAL-KEY] Loading ${publicKeys.keys.length} key pairs for ${identifier}`);
                const keyPairs = [];
                for (let i = 0; i < publicKeys.keys.length; i++) {
                    const pubKey = publicKeys.keys[i] as JsonWebKey;
                    const privKey = privateKeys.keys[i] as JsonWebKey;

                    // Log key details
                    ctx.data.logger.info(`[DUAL-KEY] Key ${i}: Algorithm=${pubKey.alg}, Type=${pubKey.kty}, Curve=${pubKey.crv || 'N/A'}`);

                    const importedPubKey = await importJwk(pubKey, 'public');
                    const importedPrivKey = await importJwk(privKey, 'private');

                    // Log the imported key's algorithm
                    ctx.data.logger.info(`[DUAL-KEY] Imported key ${i}: ${importedPubKey.algorithm.name}`);

                    keyPairs.push({
                        publicKey: importedPubKey,
                        privateKey: importedPrivKey,
                    });
                }
                ctx.data.logger.info(`[DUAL-KEY] Successfully loaded ${keyPairs.length} key pairs`);

                // IMPORTANT: Fedify will use these keys for HTTP signatures
                // The order matters - return them as-is but log for debugging
                ctx.data.logger.info(`[HTTP-SIG] Returning ${keyPairs.length} keys: [0]=Ed25519, [1]=RSA`);

                // Log which key IDs will be generated
                ctx.data.logger.info(`[HTTP-SIG] Key IDs will be: #main-key (Ed25519), #key-2 (RSA)`);

                return keyPairs;
            } else {
                // Fallback for old single key format (backward compatibility)
                ctx.data.logger.info(`[DUAL-KEY] Loading single key pair for ${identifier} (legacy format)`);
                return [
                    {
                        publicKey: await importJwk(
                            publicKeys as JsonWebKey,
                            'public',
                        ),
                        privateKey: await importJwk(
                            privateKeys as JsonWebKey,
                            'private',
                        ),
                    },
                ];
            }
        } catch (_err) {
            ctx.data.logger.warn(`Could not parse keypair for ${identifier}`);
            return [];
        }
    }
}

We modified the key generation in configuration/registrations.ts:

// When creating a new account, generate both key types
logging.info('[DUAL-KEY] Generating both Ed25519 and RSA keys');

const { generateCryptoKeyPair } = await import('@fedify/fedify');

// Generate BOTH key types
const ed25519Keys = await generateCryptoKeyPair('Ed25519');
const rsaKeys = await generateCryptoKeyPair('RSASSA-PKCS1-v1_5');

logging.info(
  '[DUAL-KEY] Successfully generated both Ed25519 and RSA key pairs',
);

// Store as array in the database
const keyPairs = [ed25519Keys, rsaKeys];

// The keys are stored in the database as JSON with a 'keys' array structure:
// ap_public_key: JSON.stringify({ keys: [ed25519PublicJwk, rsaPublicJwk] })
// ap_private_key: JSON.stringify({ keys: [ed25519PrivateJwk, rsaPrivateJwk] })

The actor dispatcher now provides both keys:

const keyPairs = await ctx.getActorKeyPairs(identifier);

const person = new Person({
  // Both keys for Object Integrity Proofs
  assertionMethods: keyPairs.map((kp) => kp.multikey),
  // Primary key for HTTP Signatures
  publicKey: keyPairs[0]?.cryptographicKey,
});

4. Context-Based URL Generation (Optional Enhancement)

While the official code uses database URLs, we chose to use Fedify's Context methods for consistency:

// Official approach - database URLs (works with proper headers)
inbox: new URL(account.ap_inbox_url),
outbox: new URL(account.ap_outbox_url),
// Our approach - Context methods (cleaner, ensures consistency)
const person = new Person({
  id: ctx.getActorUri(identifier),
  inbox: ctx.getInboxUri(identifier),
  outbox: ctx.getOutboxUri(identifier),
  following: ctx.getFollowingUri(identifier),
  followers: ctx.getFollowersUri(identifier),
  liked: ctx.getLikedUri(identifier),
});

Note: Our testing showed both approaches work correctly when nginx headers are properly configured. The Context methods ensure HTTPS URLs are generated based on the X-Forwarded-Proto header.

Infrastructure Configuration

ECS Task Definition (CDK)

The task definition runs three containers with specific configurations:

// Init container - creates database and runs migrations
const initContainer = taskDefinition.addContainer('ActivityPubInit', {
  image: ecs.ContainerImage.fromRegistry(
    'ghcr.io/tryghost/activitypub-migrations:edge',
  ),
  environment: {
    MYSQL_HOST: props.databaseEndpoint,
  },
  secrets: {
    MYSQL_USER: ecs.Secret.fromSecretsManager(props.databaseSecret, 'username'),
    MYSQL_PASSWORD: ecs.Secret.fromSecretsManager(
      props.databaseSecret,
      'password',
    ),
  },
  command: [
    'sh',
    '-c',
    `
    mysql -h "$MYSQL_HOST" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" \\
      -e "CREATE DATABASE IF NOT EXISTS activitypub CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"

    export MYSQL_DB="mysql://$MYSQL_USER:$MYSQL_PASSWORD@tcp($MYSQL_HOST:3306)/activitypub"
    /usr/bin/migrate -database "$MYSQL_DB" -path /migrations up
  `,
  ],
  essential: false,
});

// ActivityPub container with custom image
const activityPubContainer = taskDefinition.addContainer(
  'ActivityPubContainer',
  {
    image: ecs.ContainerImage.fromAsset('containers/activitypub', {
      platform: ecrAssets.Platform.LINUX_AMD64,
    }),
    environment: {
      NODE_ENV: 'production', // CRITICAL - forces HTTPS for JWKS endpoint
      ACTIVITYPUB_KEY_ALGORITHM: 'ed25519',
      MYSQL_HOST: props.databaseEndpoint,
      MYSQL_DATABASE: 'activitypub',
      USE_MQ: 'false', // No Pub/Sub in AWS
      ALLOW_PRIVATE_ADDRESS: 'true', // Allow container networking
      SKIP_SIGNATURE_VERIFICATION: 'true', // For internal requests
      GHOST_URL: `https://${props.domainName}`,
      SITE_ORIGIN: `https://${props.domainName}`,
      FEDIFY_KV_STORE_TYPE: 'knex', // Database-backed storage
      PORT: '8080',
    },
    portMappings: [
      {
        containerPort: 8080,
        protocol: ecs.Protocol.TCP,
      },
    ],
    essential: true,
  },
);

// Ghost container configuration
const ghostContainer = taskDefinition.addContainer('GhostContainer', {
  environment: {
    NODE_ENV: 'production',
    url: `https://${props.domainName}`,
    // Tell Ghost where ActivityPub is
    activitypub__target: 'http://127.0.0.1:80', // Through nginx
    activitypub__url: `https://${props.domainName}`,
    activitypub__webhook_secret: webhookSecret,
  },
});

Nginx Proxy Configuration

The nginx proxy translates headers and routes ActivityPub traffic:

server {
    listen 80;
    server_name _;

    set $domain_name "${DOMAIN_NAME}";

    # Route ActivityPub federation endpoints
    location /.ghost/activitypub/ {
        # CRITICAL: Set correct headers for HTTPS detection
        proxy_set_header Host $domain_name;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;

        # Proxy to ActivityPub container
        proxy_pass http://127.0.0.1:8080;
        proxy_buffering off;
        proxy_read_timeout 120s;
    }

    # Handle federation discovery endpoints
    location ~ ^/\.well-known/(webfinger|nodeinfo) {
        proxy_set_header Host $domain_name;
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Real-IP $remote_addr;

        proxy_pass http://127.0.0.1:8080;
        proxy_buffering off;
    }

    # Route to Ghost CMS
    location / {
        # Translate CloudFront header
        set $proto $http_cloudfront_forwarded_proto;
        if ($proto = '') {
            set $proto 'https';
        }

        proxy_set_header X-Forwarded-Proto $proto;
        proxy_set_header Host $host;
        proxy_pass http://127.0.0.1:2368;

        # WebSocket support for Ghost admin
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Custom Dockerfile

We build a custom ActivityPub container with our fixes:

FROM node:22-slim

RUN apt-get update && apt-get install -y \
    python3 \
    g++ \
    make \
    ca-certificates \
    xz-utils \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /opt/activitypub

COPY package.json .
COPY yarn.lock .

RUN yarn && \
    yarn cache clean

COPY tsconfig.json .

COPY src ./src
COPY vitest.config.ts vitest.config.ts

ENV NODE_ENV=production
RUN yarn build

RUN apt-get purge -y python3 g++ make && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*

EXPOSE 8080

CMD ["node", "dist/app.js"]

Production Testing Evidence

We tested both the official container and our fixed version on https://subaud.io:

Testing Official Container (FAILED)

# Deploy official container
docker pull ghcr.io/tryghost/activitypub:edge

# Test actor endpoint
curl -s -H "Accept: application/activity+json" \
  "https://subaud.io/.ghost/activitypub/users/index" | jq '.publicKey, .assertionMethod'

# Result:
null
null

Result: Even with perfect nginx header translation, federation fails completely due to missing keys.

Testing Our Fixed Container (SUCCESS)

# Deploy our custom container with fixes

# Test actor endpoint
curl -s -H "Accept: application/activity+json" \
  "https://subaud.io/.ghost/activitypub/users/index" | jq '.publicKey, .assertionMethod'

# Result:
{
  "id": "https://subaud.io/.ghost/activitypub/users/index#main-key",
  "type": "CryptographicKey",
  "owner": "https://subaud.io/.ghost/activitypub/users/index",
  "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAD/tDawDPRbL4BrBZnO5bn1woTIR2Ogt6YVCKcCTh6bM=\n-----END PUBLIC KEY-----\n"
}
[
  {
    "id": "https://subaud.io/.ghost/activitypub/users/index#main-key",
    "type": "Multikey",
    "controller": "https://subaud.io/.ghost/activitypub/users/index",
    "publicKeyMultibase": "z6MkfXeNxz4qTRnnW8vCauMgN9qBfqezj7xHaWme72TWHR3G"
  },
  {
    "id": "https://subaud.io/.ghost/activitypub/users/index#key-2",
    "type": "Multikey",
    "controller": "https://subaud.io/.ghost/activitypub/users/index",
    "publicKeyMultibase": "zgghBUVkqmWS8e1irHiFpg..."
  }
]

Verifying Dual-Key Support

# Count keys (should be 2 for dual-key support)
curl -s -H "Accept: application/activity+json" \
  "https://subaud.io/.ghost/activitypub/users/index" | \
  jq '.assertionMethod | length'

# Result: 2

Federation Testing

# WebFinger discovery
curl -s "https://subaud.io/.well-known/webfinger?resource=acct:index@subaud.io" | jq .

# Mastodon search: @index@subaud.io
# Result: Account discoverable, can follow, posts federate

Testing Your Federation

Once deployed with both infrastructure and container fixes:

# Verify keys are present (MUST NOT be null)
curl -s -H "Accept: application/activity+json" \
  "https://yourdomain.com/.ghost/activitypub/users/index" | jq '.publicKey'

# Check webfinger discovery
curl -s "https://yourdomain.com/.well-known/webfinger?resource=acct:index@yourdomain.com" | jq .

# Test with browser.pub
https://browser.pub/@index@yourdomain.com

# Follow from Mastodon
# Search for: @index@yourdomain.com

Why This Architecture Works

Header Translation

CloudFront sends CloudFront-Forwarded-Proto: https but ActivityPub expects X-Forwarded-Proto. The nginx proxy translates between them, ensuring ActivityPub correctly detects HTTPS.

Container Orchestration

Running all containers in a single ECS task ensures they scale together and share networking. They communicate over localhost, avoiding network complexity.

Dual-Key Cryptography

Supporting both Ed25519 and RSA keys ensures compatibility with all Fediverse implementations. Mastodon gets RSA for HTTP signatures, while newer platforms can use Ed25519.

Production Mode

Setting NODE_ENV=production forces ActivityPub to use HTTPS for all endpoints, including the JWKS endpoint that Ghost uses for authentication.

The Complete Solution: Infrastructure + Container Fixes

Important: Both infrastructure configuration AND container code changes are required for working federation on AWS:

What nginx headers alone fix:

The nginx header translation ensures HTTPS URL generation in actor endpoints, but federation remains completely broken without the key attachment fix.

What container fixes provide:

The container modifications restore basic federation by attaching cryptographic keys to the Person object. The dual-key support maximizes compatibility across different Fediverse platforms, and the class-based architecture resolves build issues with nested functions. Together, these changes enable full working federation.

Why You Need Both:

The nginx proxy translates CloudFront headers so ActivityPub generates HTTPS URLs, while the custom container ensures keys are properly attached to the Person object. Without the nginx proxy, you get HTTP URLs that break some servers. Without the container fixes, you get null keys that break all servers.

Conclusion

Running Ghost's ActivityPub on AWS requires fixing both infrastructure challenges and container bugs. Our production testing definitively proved that the official container has a fundamental issue with missing key attachment, and that nginx header translation alone, while enabling HTTPS URLs, doesn't restore federation. Only by applying both fixes together did we achieve working federation.

The solution demonstrates that self-hosted Ghost can participate in the Fediverse on AWS, but requires a custom container build until the bugs are fixed upstream. These minimal code changes transform Ghost ActivityPub from completely non-functional to fully federated with the entire Fediverse.

Our production deployment at https://subaud.io has been successfully federating since September 2025, proving these fixes work reliably at scale.