After four months of running a fully self-hosted Ghost CMS on AWS, I made a decision that might seem counterintuitive: I moved to Ghost Pro managed hosting. But here's the thing—I kept all the custom Lambda-powered features I'd built. This post documents that migration and explains why the hybrid approach makes sense.

Why Move Away from Self-Hosted?

Running Ghost on AWS taught me a lot about ECS, Aurora Serverless, and CloudFront configuration. The infrastructure worked well. Posts published, newsletters sent, images served from S3. But operational overhead accumulated.

Aurora Serverless charges added up even during idle periods. The NAT Gateway ran constantly for container egress. ECS tasks needed monitoring. Database backups required verification. And when Ghost released ActivityPub federation support, I discovered that CloudFront sitting in front of Ghost broke the WebFinger protocol that federation depends on.

The final push came from looking at my AWS bill. October 2025 cost $207 for infrastructure alone. Add $35 for Mailgun (Ghost requires external email for newsletters), and I was spending $242 monthly to run a blog.

The Hybrid Architecture

Ghost Pro handles what Ghost does best: content management, theme rendering, member authentication, newsletter delivery, and now ActivityPub federation. Ghost's team manages updates, security patches, and infrastructure scaling.

AWS Lambda handles what I built specifically for my workflow: automatic LLMs.txt generation for AI indexing, time-gated paywall management that transitions paid content to free after a configurable period, and weekly digest newsletter compilation.

The key insight is that Ghost's webhook system and Admin API remain fully functional on Ghost Pro. Every custom feature I built triggers from webhooks or scheduled events and communicates through the API. The hosting location doesn't matter.

Architecture Before and After

The self-hosted architecture looked like this: CloudFront received requests and forwarded them to an Application Load Balancer, which routed to ECS Fargate running Ghost with an Nginx sidecar. Ghost connected to Aurora Serverless for the database and EFS for content storage. Images lived in S3 at images.subaud.io.

flowchart LR
    User((User)) --> CF

    subgraph AWS
        direction TB
        CF[CloudFront] --> WAF --> ALB[Load Balancer]

        ALB --> ECS

        subgraph ECS[ECS Fargate]
            Nginx[Nginx] --> Ghost[Ghost]
        end

        Ghost --> Aurora[(Aurora)]
        Ghost --> EFS[(EFS)]
        Ghost --> S3[S3 Images]

        Ghost -.->|webhooks| Lambda

        subgraph Lambda[Lambda Functions]
            direction LR
            LLM[LLMs.txt]
            Paywall[Paywall]
            Digest[Digest]
        end
    end

After migration, the architecture simplified dramatically. Users hit Ghost Pro directly at subaud.io. Ghost Pro sends webhooks to api.subaud.io, which routes through CloudFront to API Gateway and Lambda. The Lambda functions write LLMs.txt files to S3, served at llms.subaud.io through CloudFront.

flowchart LR
    User((User)) --> Ghost

    subgraph GhostPro[Ghost Pro]
        Ghost[Ghost CMS]
    end

    Ghost -.->|webhooks| CF

    subgraph AWS
        direction TB
        CF[CloudFront] --> APIGW[API Gateway]

        APIGW --> Lambda

        subgraph Lambda[Lambda Functions]
            direction LR
            LLM[LLMs.txt]
            Paywall[Paywall]
            Digest[Digest]
        end

        Lambda --> S3[(S3)]
    end

DNS configuration reflects this split. The apex domain points directly to Ghost Pro's IP address (178.128.137.126). The www subdomain CNAMEs to subaud.ghost.io. The api and llms subdomains point to CloudFront distributions that I still manage.

The Migration Process

Exporting content from self-hosted Ghost was straightforward. Ghost Admin provides for exporting to JSON that includes posts, pages, tags, and members. I downloaded these and imported them directly into Ghost Pro.

Configuration migration required more work. Ghost's export doesn't include tiers, newsletters, labels, or offers. I wrote a TypeScript script that reads these from the source Ghost instance via Admin API and recreates them on the destination.

Migrating Images from S3 to Ghost Pro

Image migration turned out to be the most complex part of the process. My self-hosted Ghost stored images in S3 at images.subaud.io, served through CloudFront. Ghost Pro uses their own CDN with a different URL structure. Every image reference in every post needed to change.

Ghost's export doesn't include images—just the JSON content with embedded image URLs. The standard import process expects you to either keep your old image hosting running forever or manually re-upload everything. But creating new posts would change the location of the image to the Ghost Pro source. This would result in a scenario where some images were hosted on S3 and others were hosted within the Ghost Pro CDN. I opted to remove the existing S3 source entirely so we could delete the Bucket and use the Ghost Pro CDN exclusively.

To do this, I built a migration script that handles this migration automatically. The script runs in four phases: download all images from S3, upload each to Ghost Pro via the Admin API, build a mapping of old URLs to new URLs, then update every post with the new references.

flowchart LR
    subgraph Phase1["Phase 1: Download"]
        S3[(S3 Bucket)] --> Download[Download Images]
        Download --> TempDir[Temp Directory]
    end

    subgraph Phase2["Phase 2: Upload"]
        TempDir --> Upload[Upload to Ghost Pro]
        Upload --> GhostCDN[Ghost CDN]
    end

    subgraph Phase3["Phase 3: Map"]
        Upload --> Mapping[Build URL Mapping]
        Mapping --> JSON[(mapping.json)]
    end

    subgraph Phase4["Phase 4: Update"]
        JSON --> Update[Update Posts]
        Update --> GhostAPI[Ghost Admin API]
    end

The S3 download phase lists all objects in the bucket and downloads each image file. Ghost generates optimized versions of images with an _o suffix before the extension—files like hero_o.png. The script skips these since Ghost Pro regenerates optimized versions automatically. Downloading 163 images took about two minutes.

Uploading to Ghost Pro uses the Admin API's image upload endpoint. Each image gets a new URL on Ghost's CDN in the format /content/images/2025/12/filename.png. The script tracks the mapping between old and new URLs as it processes each file:

const mapping: { [oldUrl: string]: string } = {};

for (const localPath of downloadedFiles) {
  const relativePath = path.relative(tempDir, localPath);
  const oldUrl = `https://images.subaud.io/${relativePath}`;
  const newUrl = await uploadImageToGhost(ghostUrl, adminKey, localPath);
  mapping[oldUrl] = newUrl;
}

The URL update phase is where things get interesting. Ghost stores post content in two formats: Mobiledoc (the older format) and Lexical (the newer format). Both are JSON structures with image URLs embedded at various depths. The script fetches every post and page, checks each content field for old URLs, and updates them:

sequenceDiagram
    participant Script
    participant GhostAPI as Ghost Admin API
    participant Post as Post Content

    Script->>GhostAPI: GET /posts/?formats=mobiledoc,lexical
    GhostAPI-->>Script: All posts with content

    loop Each Post
        Script->>Post: Check feature_image
        Script->>Post: Check mobiledoc JSON
        Script->>Post: Check lexical JSON
        Script->>Post: Check og_image, twitter_image

        alt URLs found
            Script->>Script: Replace old URLs with new
            Script->>GhostAPI: PUT /posts/{id}
        end
    end

The script handles several edge cases that aren't immediately obvious. URL-encoded paths appear in some content—spaces become %20, for example. The script replaces both encoded and unencoded versions. Feature images, Open Graph images, and Twitter card images all live in separate fields from the main content. Each needs checking.

Running the migration with --dry-run first showed exactly what would change without modifying anything. The actual migration processed 163 images and updated 61 posts in about five minutes. The script saves the URL mapping to a JSON file, useful for debugging if any images got missed.

GHOST_URL=https://subaud.ghost.io \
GHOST_ADMIN_KEY=id:secret \
S3_BUCKET=ghoststack-storageimagesbucketcec17a37-e9nrwt0kyare \
npx ts-node scripts/migrate-images-to-ghost-pro.ts

One thing I didn't anticipate: Ghost Pro's Admin API requires a fresh JWT token for each request. The token expires after five minutes. The script generates tokens on demand using the Admin API key, which comes in id:secret format. The secret is hex-encoded and gets used as an HMAC key for signing.

After migration, I could verify success by viewing any post in the Ghost editor. Images that failed to migrate would show as broken. All 163 showed up correctly. The old S3 bucket and CloudFront distribution can now be deleted without breaking anything.

DNS and the ActivityPub Requirement

Ghost Pro's custom domain setup has a specific requirement: your DNS must point directly to Ghost's servers. No CDN in front. No proxy. The A record for your apex domain points to Ghost Pro's IP address, and the www CNAME points to your-site.ghost.io.

This requirement exists because of ActivityPub federation. When someone on Mastodon searches for @subaud.io, their server sends a WebFinger request to subaud.io/.well-known/webfinger. Ghost needs to respond with federation metadata that includes cryptographic signatures. A CDN caching or modifying these responses breaks the signature verification.

The fix is architectural: the main domain goes to Ghost Pro, and subdomains handle everything else. My Lambda functions still need CloudFront and API Gateway. The LLMs.txt files still live in S3. These now live at api.subaud.io and llms.subaud.io instead of paths under the main domain.

The LLMs.txt Challenge

LLMs.txt files make blog content discoverable by AI systems. The idea is simple: provide machine-readable summaries at predictable URLs. The implementation with Ghost Pro required solving a routing problem that Ghost's redirect system handles elegantly.

The original self-hosted setup served LLMs.txt files directly through CloudFront. Requests to subaud.io/llms.txt hit CloudFront, which checked S3 for the file. Per-post files lived at subaud.io/blog/post-slug/llms.txt. Everything ran through the same infrastructure.

With Ghost Pro, the main domain must point to Ghost's servers. That means requests to subaud.io/llms.txt hit Ghost, not my S3 bucket. Ghost doesn't know about these files—they're generated by Lambda and stored in S3.

The solution uses Ghost Pro's redirect system combined with a dedicated subdomain. The Lambda function writes files to S3, CloudFront serves them at llms.subaud.io, and Ghost Pro redirects requests from the main domain to the subdomain.

sequenceDiagram
    participant User
    participant GhostPro as Ghost Pro<br/>subaud.io
    participant CF as CloudFront<br/>llms.subaud.io
    participant S3 as S3 Bucket

    User->>GhostPro: GET /llms.txt
    GhostPro-->>User: 301 Redirect to<br/>llms.subaud.io/llms.txt
    User->>CF: GET /llms.txt
    CF->>S3: Fetch llms.txt
    S3-->>CF: File content
    CF-->>User: llms.txt content

Ghost Pro's redirects.yaml file defines the routing rules. The syntax supports both static paths and dynamic slugs:

301:
  /llms.txt: https://llms.subaud.io/llms.txt
  /llms-full.txt: https://llms.subaud.io/llms-full.txt
  /:slug/llms.txt: https://llms.subaud.io/:slug/llms.txt

The :slug placeholder captures any post slug and passes it through to the redirect target. A request for subaud.io/ghost-on-aws-core-infrastructure/llms.txt redirects to llms.subaud.io/ghost-on-aws-core-infrastructure/llms.txt.

The Lambda function generates files at matching paths. When a webhook fires for a post publish event, the function converts the post HTML to Markdown, builds the LLMs.txt content, and uploads to S3 at {slug}/llms.txt. It also regenerates the main llms.txt index that lists all posts.

sequenceDiagram
    participant Ghost as Ghost Pro
    participant APIGW as API Gateway<br/>api.subaud.io
    participant Lambda as LLMs.txt<br/>Lambda
    participant GhostAPI as Ghost<br/>Content API
    participant S3 as S3 Bucket

    Ghost->>APIGW: Webhook: post.published
    APIGW->>Lambda: Invoke with payload
    Lambda->>Lambda: Validate signature
    Lambda->>Lambda: Check visibility = public
    Lambda->>Lambda: Convert HTML to Markdown
    Lambda->>S3: PUT {slug}/llms.txt
    Lambda->>GhostAPI: GET /posts (all public)
    GhostAPI-->>Lambda: Posts list
    Lambda->>Lambda: Generate index
    Lambda->>S3: PUT llms.txt
    Lambda-->>APIGW: 200 OK

The function only generates LLMs.txt files for public posts. Paid or members-only content gets skipped to avoid exposing content that should be behind a paywall. The visibility check happens before any file generation:

if (post.visibility !== 'public') {
  console.log(`Post ${post.slug} has restricted visibility, skipping`);
  return { statusCode: 200, body: 'Skipped - restricted visibility' };
}

The redirect approach has a useful property: it preserves the canonical URL structure. AI crawlers and search engines see requests to subaud.io/llms.txt redirect to llms.subaud.io/llms.txt. The 301 status code tells them the new location is permanent. Links to the old URLs continue working.

Updating the Lambda Stack

The GhostProAddons CDK stack contains only the Lambda functions and their supporting infrastructure. No ECS, no database, no ALB. It creates an API Gateway for webhook reception, Lambda functions for LLMs.txt generation and paywall management, a DynamoDB table for tracking paid post transitions, S3 buckets for generated files, and CloudFront distributions for the api and llms subdomains.

Deployment uses the same CDK patterns as before, just with a different environment file:

ENVIRONMENT=ghost-pro pnpm cdk deploy GhostProAddons

The Lambda functions needed one change: the GHOST_API_URL environment variable now points to Ghost Pro instead of the self-hosted instance. The Content API key and Admin API key come from a new integration created in Ghost Pro's settings.

Cost Comparison with Real Numbers

Here's what self-hosted Ghost actually cost, pulled directly from AWS Cost Explorer:

September 2025 totaled $192.63. October came to $207.61. November hit $269.20, though that included a $65 domain registration. The average for full months was $207.

Breaking down October: Aurora Serverless cost $48.47. ECS Fargate cost $35.55. The NAT Gateway (hidden in EC2-Other) cost $34.98. AWS WAF cost $26.09. CloudWatch cost $18.58. The ALB cost $16.76. Those six services accounted for 87% of the bill.

Add Mailgun at $35 monthly for newsletters and transactional email, and the true cost was $242 per month.

Ghost Pro Publisher tier costs $29 monthly. It includes email delivery, eliminating Mailgun. The Lambda add-ons cost essentially nothing—free tier covers the usage. S3 and CloudFront for the subdomains add maybe $2. Route 53 adds $0.50.

Total: approximately $32 monthly. That's an 87% reduction, saving $210 per month or $2,520 annually.

Lessons from the Migration

ActivityPub federation drove the architectural decision. Without that requirement, I might have kept CloudFront in front of everything. The federation constraint forced a cleaner separation between what Ghost handles and what my custom code handles.

Ghost creates internal tags during import with names like #Import 2025-12-10. These showed up in my LLMs.txt output until I deleted them via the Admin API.

The webhook URL changed from subaud.io/api/webhook/llms (routed through the old CloudFront distribution) to api.subaud.io/webhook/llms (direct to the new API Gateway distribution).

The Hybrid Model Works

Four months of self-hosted Ghost proved that custom Lambda features add real value. The LLMs.txt generator makes content discoverable by AI systems. The paywall manager automates a manual workflow. The weekly digest compiles posts without intervention.

Ghost Pro proved that managed hosting handles the core CMS better than I can. Updates happen automatically. Email delivery just works. ActivityPub federation connects to the Fediverse without custom container modifications.

The combination costs less than either approach alone would. Ghost Pro at $29 plus minimal Lambda costs beats $242 for full self-hosted. And it beats trying to replicate my custom features on a platform that doesn't support webhooks or APIs.

You don't have to choose between managed hosting and custom features. Ghost Pro's webhook system and API let you build exactly the integrations you need while letting their team handle infrastructure you shouldn't have to think about.