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.