Ghost requires a MySQL database, persistent storage for content and images, and a way to handle traffic at scale. AWS provides these through RDS Aurora, EFS, S3, and ECS Fargate. This post walks through deploying the core infrastructure using AWS CDK, which allows us to define everything as TypeScript code rather than clicking through the AWS console.

The infrastructure we're building costs approximately $175 per month. The largest costs are the database ($47/month) and NAT Gateway ($32/month) needed for containers to reach external services. Everything deploys with a single CDK command and can be torn down just as easily.

The Architecture

The system consists of Ghost containers with Nginx sidecars running on ECS Fargate, connected to an Aurora Serverless MySQL database, with persistent content stored on EFS. An Application Load Balancer handles incoming traffic, containers in private subnets reach the internet through a NAT Gateway, and CloudFront provides global caching.

graph TB
    subgraph Internet
        Users[Users]
        External[External Services<br/>Mailgun, Docker Hub]
    end
    
    subgraph AWS
        CF[CloudFront CDN]
        ALB[Application Load Balancer]
        
        subgraph VPC
            subgraph Public Subnet
                NAT[NAT Gateway]
            end
            
            subgraph Private Subnet
                subgraph ECS Fargate
                    Nginx1[Nginx Sidecar]
                    Ghost1[Ghost Container]
                    Nginx2[Nginx Sidecar]
                    Ghost2[Ghost Container]
                end
            end
            
            subgraph Data Subnet
                RDS[(Aurora Serverless)]
                EFS[EFS File System]
            end
        end
        
        S3[S3 Image Storage]
    end
    
    Users --> CF
    CF --> ALB
    ALB --> Nginx1
    ALB --> Nginx2
    Nginx1 --> Ghost1
    Nginx2 --> Ghost2
    Ghost1 --> RDS
    Ghost2 --> RDS
    Ghost1 --> EFS
    Ghost2 --> EFS
    Ghost1 --> S3
    Ghost2 --> S3
    Ghost1 --> NAT
    Ghost2 --> NAT
    NAT --> External

Each component serves a specific purpose. CloudFront caches static assets globally. The ALB distributes traffic to Nginx sidecars which handle header translation before passing requests to Ghost. ECS Fargate runs the containers without managing servers. Aurora Serverless provides a MySQL database that scales automatically. EFS provides persistent storage for Ghost content that survives container restarts. The NAT Gateway allows containers in private subnets to reach external services like Mailgun for email.

Building the Infrastructure

The CDK stack starts with networking. Ghost containers need to reach the internet for updates and external services, but we don't want them directly exposed. The VPC creates isolated network space with public and private subnets across two availability zones.

import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as rds from 'aws-cdk-lib/aws-rds';

export class GhostStack extends cdk.Stack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    
    // Network isolation for the application
    const vpc = new ec2.Vpc(this, 'GhostVPC', {
      maxAzs: 2,
      natGateways: 1
    });

Next comes the database. Aurora Serverless v2 was chosen because it can scale down to 0.5 ACU (about $47/month minimum) and handles variable loads well. Ghost creates its own schema on first run, so we only need to provide an empty database.

    // Database that scales with load
    const database = new rds.DatabaseCluster(this, 'GhostDB', {
      engine: rds.DatabaseClusterEngine.auroraMysql({
        version: rds.AuroraMysqlEngineVersion.VER_3_07_1
      }),
      serverlessV2MinCapacity: 0.5,
      serverlessV2MaxCapacity: 1,
      writer: rds.ClusterInstance.serverlessV2('writer'),
      vpc,
      defaultDatabaseName: 'ghost',
      backup: {
        retention: cdk.Duration.days(7)
      }
    });

Persistent storage uses EFS to maintain Ghost content across container restarts. This is essential because Ghost stores themes, images, and configuration files locally.

    // EFS for persistent Ghost content
    const fileSystem = new efs.FileSystem(this, 'GhostEFS', {
      vpc,
      performanceMode: efs.PerformanceMode.GENERAL_PURPOSE,
      throughputMode: efs.ThroughputMode.BURSTING,
      removalPolicy: cdk.RemovalPolicy.DESTROY
    });
    
    const accessPoint = fileSystem.addAccessPoint('GhostAccessPoint', {
      path: '/ghost-content',
      createAcl: {
        ownerUid: '1000',
        ownerGid: '1000',
        permissions: '755'
      },
      posixUser: {
        uid: '1000',
        gid: '1000'
      }
    });

The container setup defines how Ghost runs. We allocate 1GB of memory and 0.5 vCPU, which handles typical blog traffic well. The Ghost container expects environment variables for database connection and configuration.

    // ECS cluster for running containers
    const cluster = new ecs.Cluster(this, 'GhostCluster', {
      vpc,
      containerInsights: true
    });
    
    // Task definition specifies container requirements
    const taskDefinition = new ecs.FargateTaskDefinition(this, 'GhostTask', {
      memoryLimitMiB: 1024,
      cpu: 512
    });
    
    // Ghost container configuration
    const container = taskDefinition.addContainer('ghost', {
      image: ecs.ContainerImage.fromRegistry('ghost:5-alpine'),
      environment: {
        url: 'https://yourdomain.com',
        database__client: 'mysql',
        database__connection__host: database.clusterEndpoint.hostname,
        database__connection__database: 'ghost',
        NODE_ENV: 'production'
      },
      secrets: {
        database__connection__user: ecs.Secret.fromSecretsManager(dbSecret, 'username'),
        database__connection__password: ecs.Secret.fromSecretsManager(dbSecret, 'password')
      },
      logging: ecs.LogDrivers.awsLogs({
        streamPrefix: 'ghost'
      })
    });
    
    container.addPortMappings({
      containerPort: 2368
    });

Ghost runs alongside an Nginx sidecar container that handles header translation and health checks. This pattern solves issues with Ghost's header handling behind load balancers.

    // Add Nginx sidecar for header translation
    const nginxContainer = taskDefinition.addContainer('NginxContainer', {
      image: ecs.ContainerImage.fromAsset('docker/nginx'),
      portMappings: [{
        containerPort: 80,
        protocol: ecs.Protocol.TCP
      }],
      essential: true
    });
    
    // Mount EFS volume to Ghost container
    ghostContainer.addMountPoints({
      sourceVolume: 'ghost-content',
      containerPath: '/var/lib/ghost/content',
      readOnly: false
    });

The service manages running containers and connects them to the load balancer. Auto-scaling ensures we have enough capacity during traffic spikes without overpaying during quiet periods.

    // Service manages container lifecycle  
    const service = new ecsPatterns.ApplicationLoadBalancedFargateService(this, 'Service', {
      cluster,
      taskDefinition,
      desiredCount: 1,
      assignPublicIp: false,
      enableExecuteCommand: true  // Enable ECS Exec for debugging
    });
    
    // Scale based on CPU and memory usage
    const scaling = service.service.autoScaleTaskCount({
      minCapacity: 1,
      maxCapacity: 3
    });
    
    scaling.scaleOnCpuUtilization('CpuScaling', {
      targetUtilizationPercent: 70,
      scaleInCooldown: cdk.Duration.minutes(5)
    });
    
    scaling.scaleOnMemoryUtilization('MemoryScaling', {
      targetUtilizationPercent: 70,
      scaleInCooldown: cdk.Duration.minutes(5)
    });

Finally, the load balancer provides a stable endpoint for the application. It connects to the Nginx sidecar on port 80, which then proxies to Ghost on port 2368. Health checks go through Nginx to ensure both containers are working.

    // Configure health check through Nginx
    service.targetGroup.configureHealthCheck({
      path: '/health',
      healthyHttpCodes: '200',
      interval: cdk.Duration.seconds(30),
      timeout: cdk.Duration.seconds(5)
    });
    
    // Override to route traffic to Nginx instead of Ghost
    const cfnService = service.service.node.defaultChild as ecs.CfnService;
    cfnService.loadBalancers = [{
      containerName: 'NginxContainer',
      containerPort: 80,
      targetGroupArn: service.targetGroup.targetGroupArn
    }];
  }
}

Database Considerations

Ghost requires MySQL 8.0 or higher with specific character encoding. Aurora Serverless handles this automatically when you create the database with the correct parameters. The database needs to support utf8mb4 for emoji and special characters in content.

-- Ghost expects this character set
ALTER DATABASE ghost CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

Ghost creates all necessary tables on first startup. The schema includes tables for posts, users, members, tags, and settings. The initial migration takes about 30 seconds and happens automatically when the first container connects.

Container Configuration

The Ghost Docker image runs Alpine Linux for minimal size. The official ghost:5-alpine image includes Node.js and Ghost's core files but needs additional configuration for AWS services. Environment variables control Ghost's behavior, with the double underscore notation mapping to nested configuration objects.

# Ghost configuration through environment variables
url=https://yourdomain.com
database__client=mysql
database__connection__host=aurora-cluster.rds.amazonaws.com
database__connection__user=ghost
database__connection__password=secretpassword
database__connection__database=ghost
NODE_ENV=production

Secrets Manager stores sensitive values like database passwords. The ECS task execution role needs permission to retrieve these secrets at container startup. This keeps credentials out of your code and allows rotation without redeployment.

Scaling Behavior

The infrastructure scales at multiple levels. ECS adds container instances when CPU exceeds 70% for more than one minute. Aurora increases database capacity when connections approach the limit or query load increases. Both scale down automatically during quiet periods to minimize costs.

graph LR
    subgraph Scaling Triggers
        CPU[CPU > 70%]
        MEM[Memory > 80%]
        CON[Connections > 40]
    end
    
    subgraph Actions
        ADD[Add Container]
        REM[Remove Container]
        INC[Increase ACUs]
        DEC[Decrease ACUs]
    end
    
    CPU --> ADD
    MEM --> ADD
    CON --> INC
    
    CPU2[CPU < 50%] --> REM
    CON2[Connections < 10] --> DEC

During our highest traffic spike (a post reaching the front page of Hacker News), the system scaled from 1 to 3 containers automatically. The database scaled from 1 to 4 ACUs to handle the additional connections. Total response time stayed under 1 second throughout.

Actual Costs

Based on current AWS billing data, here are the real monthly costs:

Service Monthly Cost Notes
RDS Aurora Serverless $47 0.5-1 ACU, can't scale below 0.5
NAT Gateway $32 Required for private subnet egress
WAF $32 CloudFront protection rules
ECS Fargate $18 1-3 tasks (512 CPU, 1024 MiB)
Application Load Balancer $16 Fixed cost plus data processing
CloudWatch $20 Logs, metrics, Container Insights
EFS + S3 $10 Persistent storage and images
Total ~$175

The largest costs are the database and NAT Gateway. For development environments, you can reduce costs by:

  • Using a t3.micro RDS instance ($15/month) instead of Aurora Serverless
  • Running containers with public IPs to eliminate NAT Gateway ($32/month savings)
  • Disabling WAF for non-production ($32/month savings)

Security Considerations

Network isolation protects the infrastructure. Containers run in private subnets without public IP addresses. The database accepts connections only from the ECS security group. All traffic between components stays within the VPC.

// Principle of least privilege
const ecsSecurityGroup = new ec2.SecurityGroup(this, 'ECS-SG', {
  vpc,
  allowAllOutbound: true  // Containers need internet access
});

ecsSecurityGroup.addIngressRule(
  albSecurityGroup,  // Only ALB can reach containers
  ec2.Port.tcp(2368)
);

Secrets Manager rotates database passwords automatically. The ECS task execution role has permission to retrieve secrets but not modify them. Application logs go to CloudWatch but exclude sensitive data through Ghost's configuration.

Production Readiness

The infrastructure includes several features essential for production use. Automated backups run daily with 7-day retention. CloudWatch alarms notify when CPU exceeds 90% or database connections approach the limit. Container health checks restart unhealthy instances automatically.

Zero-downtime deployments work through ECS rolling updates. New containers start and pass health checks before old ones terminate. The load balancer continues serving traffic throughout the update process.

Next Steps

This core infrastructure provides the foundation for running Ghost on AWS. Future posts in this series will cover CloudFront caching strategies, S3 storage configuration for images, email setup with Mailgun, and monitoring with CloudWatch dashboards.