Running Ghost in containers presents a storage challenge. Container filesystems are ephemeral - when a container restarts, any locally stored files disappear. This includes all uploaded images, making local storage unsuitable for production Ghost deployments on ECS.
The solution uses S3 for persistent storage and CloudFront for global content delivery. Ghost uploads images to S3, which persist across container restarts and scale across multiple container instances. CloudFront serves these images from edge locations worldwide, reducing latency and offloading traffic from your origin.
The Storage Architecture
The infrastructure creates two separate storage systems. EFS handles Ghost's content directory for themes and configuration files that need to be shared across containers. S3 stores all uploaded images with CloudFront providing the public-facing CDN endpoint.
graph LR
subgraph Ghost Container
Ghost[Ghost CMS]
Adapter[S3 Storage Adapter]
end
subgraph Storage
EFS[EFS Volume]
S3[S3 Bucket]
end
subgraph CDN
CF[CloudFront]
OAI[Origin Access Identity]
end
subgraph Users
Visitors[Site Visitors]
end
Ghost --> EFS
Ghost --> Adapter
Adapter --> S3
OAI --> S3
CF --> OAI
Visitors --> CF
Ghost writes directly to S3 through the storage adapter. CloudFront accesses S3 through an Origin Access Identity, ensuring the bucket remains private. Site visitors receive images from CloudFront edge locations, never directly from S3.
S3 Bucket Configuration
The CDK creates an S3 bucket with versioning, encryption, and lifecycle policies. The bucket blocks all public access - only CloudFront can read the files through its Origin Access Identity.
export class GhostStorage extends Construct {
public readonly imagesBucket: s3.Bucket;
constructor(scope: Construct, id: string, props: GhostStorageProps) {
super(scope, id);
this.imagesBucket = new s3.Bucket(this, 'ImagesBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
versioned: true,
lifecycleRules: [
{
id: 'TransitionToIA',
enabled: true,
transitions: [
{
storageClass: s3.StorageClass.INFREQUENT_ACCESS,
transitionAfter: cdk.Duration.days(90),
},
{
storageClass: s3.StorageClass.INTELLIGENT_TIERING,
transitionAfter: cdk.Duration.days(180),
},
],
},
{
id: 'DeleteOldVersions',
enabled: true,
noncurrentVersionExpiration: cdk.Duration.days(30),
},
],
cors: [
{
allowedHeaders: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.PUT,
s3.HttpMethods.POST,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD,
],
allowedOrigins: ['*'],
exposedHeaders: ['ETag'],
maxAge: 3000,
},
],
removalPolicy: cdk.RemovalPolicy.RETAIN,
});
}
}
Lifecycle rules automatically transition older images to cheaper storage tiers. Images move to Infrequent Access after 90 days and Intelligent Tiering after 180 days. Old versions from the versioning system delete after 30 days to control costs.
CloudFront Distribution
Two CloudFront distributions serve the Ghost site. The main distribution handles application traffic through the load balancer while a separate distribution serves images from S3. A wildcard certificate covers both the main domain and all subdomains.
export class GhostCdn extends Construct {
public readonly distribution: cloudfront.Distribution;
public readonly imagesDistribution: cloudfront.Distribution;
public readonly certificate: acm.Certificate;
constructor(scope: Construct, id: string, props: GhostCdnProps) {
super(scope, id);
// Create certificate for main domain and wildcard
this.certificate = new acm.Certificate(this, 'Certificate', {
domainName: props.domainName,
subjectAlternativeNames: [`*.${props.domainName}`],
validation: acm.CertificateValidation.fromDns(hostedZone),
});
// Create Origin Access Identity for S3 access
const oai = new cloudfront.OriginAccessIdentity(this, 'ImagesOAI', {
comment: 'OAI for Ghost images bucket',
});
// Grant CloudFront read access to S3
props.imagesBucket.grantRead(oai);
// Create images distribution
this.imagesDistribution = new cloudfront.Distribution(
this,
'ImagesDistribution',
{
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessIdentity(
props.imagesBucket,
{
originAccessIdentity: oai,
},
),
viewerProtocolPolicy:
cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: new cloudfront.CachePolicy(this, 'ImagesCachePolicy', {
defaultTtl: cdk.Duration.days(365),
maxTtl: cdk.Duration.days(365),
minTtl: cdk.Duration.days(1),
enableAcceptEncodingGzip: true,
enableAcceptEncodingBrotli: true,
queryStringBehavior: cloudfront.CacheQueryStringBehavior.all(),
}),
responseHeadersPolicy: new cloudfront.ResponseHeadersPolicy(
this,
'ImagesResponseHeadersPolicy',
{
corsBehavior: {
accessControlAllowOrigins: [
`https://${props.domainName}`,
`https://www.${props.domainName}`,
],
accessControlAllowHeaders: ['*'],
accessControlAllowMethods: ['GET', 'HEAD', 'OPTIONS'],
accessControlAllowCredentials: false,
originOverride: true,
},
securityHeadersBehavior: {
contentTypeOptions: { override: true },
frameOptions: {
frameOption: cloudfront.HeadersFrameOption.DENY,
override: true,
},
referrerPolicy: {
referrerPolicy:
cloudfront.HeadersReferrerPolicy
.STRICT_ORIGIN_WHEN_CROSS_ORIGIN,
override: true,
},
xssProtection: {
protection: true,
modeBlock: true,
override: true,
},
},
},
),
compress: true,
},
domainNames: [`images.${props.domainName}`],
certificate: this.certificate,
priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
},
);
}
}
The Origin Access Identity ensures only CloudFront can access the S3 bucket. The response headers policy adds both CORS headers for the Ghost domain and security headers including XSS protection, frame options, and referrer policy. The cache policy sets a one-year TTL for images since Ghost generates unique filenames for each upload.
WAF Protection
The main CloudFront distribution includes WAF (Web Application Firewall) protection to defend against common web exploits and control access to the application.
this.webAcl = new wafv2.CfnWebACL(this, 'WebAcl', {
scope: 'CLOUDFRONT',
defaultAction: { allow: {} },
rules: [
{
name: 'AllowGhostAdminAndWebhook',
priority: 0,
action: { allow: {} },
statement: {
orStatement: {
statements: [
{
byteMatchStatement: {
searchString: '/ghost/',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'STARTS_WITH',
},
},
{
byteMatchStatement: {
searchString: '/api/webhook',
fieldToMatch: { uriPath: {} },
textTransformations: [{ priority: 0, type: 'NONE' }],
positionalConstraint: 'EXACTLY',
},
},
],
},
},
},
{
name: 'RateLimitRule',
priority: 1,
action: { block: {} },
statement: {
rateBasedStatement: {
aggregateKeyType: 'IP',
limit: 1000, // 1000 requests per 5 minutes
},
},
},
{
name: 'AWSManagedRulesCommonRuleSet',
priority: 2,
overrideAction: { none: {} },
statement: {
managedRuleGroupStatement: {
vendorName: 'AWS',
name: 'AWSManagedRulesCommonRuleSet',
},
},
},
],
});
The WAF configuration allows Ghost admin paths and webhook endpoints to bypass protection rules, implements rate limiting (1000 requests per 5 minutes per IP), and applies AWS managed rules for common vulnerabilities.
Container Architecture
The Ghost deployment uses a sidecar pattern with two containers per task. An Nginx container handles incoming requests and header translation while the Ghost container runs the application.
Nginx Sidecar
The Nginx sidecar exists to solve a specific problem that causes Ghost to enter an infinite redirect loop. CloudFront sends a header called CloudFront-Forwarded-Proto
to indicate whether the original request was HTTP or HTTPS. Ghost, however, only recognizes the standard X-Forwarded-Proto
header. Without this header, Ghost assumes all requests are insecure HTTP connections and responds with a 301 redirect to the HTTPS version of the URL. CloudFront receives this redirect, makes another request, Ghost redirects again, and the cycle continues forever.
We initially tried to solve this with Lambda@Edge, adding code to translate the header at CloudFront's edge locations. This failed immediately because AWS prohibits Lambda@Edge functions from modifying the X-Forwarded-Proto
header for security reasons. The function wouldn't even deploy.
The working solution was to add Nginx as a sidecar container in the ECS task. Since both containers share the same network namespace in Fargate's awsvpc mode, Nginx can receive requests from the ALB on port 80 and proxy them to Ghost on localhost:2368. The critical part of the configuration is the header translation:
server {
listen 80;
server_name _;
location / {
# Translate CloudFront-Forwarded-Proto to X-Forwarded-Proto
set $proto $http_cloudfront_forwarded_proto;
if ($proto = '') {
set $proto 'https';
}
# Proxy headers for Ghost
proxy_set_header X-Forwarded-Proto $proto;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
# Important: Tell Ghost to trust proxy headers
proxy_set_header X-Forwarded-Server $host;
# Proxy to Ghost container (ECS task networking)
proxy_pass http://127.0.0.1:2368;
# WebSocket support for Ghost admin real-time features
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Disable buffering for real-time features
proxy_buffering off;
# Increase max body size for Ghost uploads
client_max_body_size 50M;
}
# Health check endpoint for ALB
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
The configuration also handles WebSocket connections for Ghost's real-time admin features, increases the upload size limit to 50MB for themes and large images, and provides a health check endpoint that the ALB uses to verify the container is running. Without this Nginx layer, Ghost behind CloudFront simply doesn't work.
Custom Ghost Image
Ghost needs the S3 storage adapter installed. The custom Docker image extends the official Ghost image with the adapter pre-installed and a startup script that copies it to the correct location.
FROM --platform=linux/amd64 ghost:6-alpine
USER root
# Install the S3 storage adapter globally
RUN npm install -g ghost-storage-adapter-s3
# Create startup script to install adapter to content directory
RUN echo '#!/bin/sh' > /usr/local/bin/docker-entrypoint.sh && \
echo 'set -e' >> /usr/local/bin/docker-entrypoint.sh && \
echo '' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'if [ ! -d "/var/lib/ghost/content/adapters/storage/s3" ]; then' >> /usr/local/bin/docker-entrypoint.sh && \
echo ' mkdir -p /var/lib/ghost/content/adapters/storage' >> /usr/local/bin/docker-entrypoint.sh && \
echo ' cp -r /usr/local/lib/node_modules/ghost-storage-adapter-s3 /var/lib/ghost/content/adapters/storage/s3' >> /usr/local/bin/docker-entrypoint.sh && \
echo ' chown -R node:node /var/lib/ghost/content/adapters' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'fi' >> /usr/local/bin/docker-entrypoint.sh && \
echo 'exec su-exec node node current/index.js' >> /usr/local/bin/docker-entrypoint.sh && \
chmod +x /usr/local/bin/docker-entrypoint.sh
WORKDIR /var/lib/ghost
EXPOSE 2368
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
The startup script checks if the adapter exists in the content directory. If not, it copies the globally installed adapter to the correct location before starting Ghost. This ensures the adapter persists on the EFS volume.
Ghost Configuration
Environment variables configure Ghost to use S3 storage with the CloudFront URL for serving images. The ECS task definition passes these to the Ghost container.
const ghostContainer = taskDefinition.addContainer('GhostContainer', {
image: ecs.ContainerImage.fromAsset('docker', {
platform: ecrAssets.Platform.LINUX_AMD64,
}),
environment: {
NODE_ENV: 'production',
url: `https://${props.domainName}`,
storage__active: 's3',
storage__s3__bucket: props.imagesBucket.bucketName,
storage__s3__region: cdk.Stack.of(this).region,
storage__s3__assetHost: `https://images.${props.domainName}`,
storage__s3__forcePathStyle: 'false',
storage__s3__acl: 'private',
},
});
The storage__s3__assetHost
setting tells Ghost to serve images from the CloudFront distribution URL instead of S3 directly. This ensures all image URLs in content point to the CDN.
IAM Permissions
The ECS task role needs permissions to access both S3 for images and EFS for persistent content. The CDK grants these permissions when creating the service.
const taskRole = new iam.Role(this, 'TaskRole', {
assumedBy: new iam.ServicePrincipal('ecs-tasks.amazonaws.com'),
description: 'Role for Ghost ECS tasks',
});
// Grant S3 permissions for image storage
props.imagesBucket.grantReadWrite(taskRole);
props.imagesBucket.grantPutAcl(taskRole);
// Grant EFS permissions for content persistence
props.fileSystem.grantReadWrite(taskDefinition.taskRole);
These permissions allow Ghost to upload images to S3 and maintain persistent content on EFS. The S3 bucket remains private with only the task role and CloudFront OAI having access.
CORS and Security Headers
The S3 bucket needs CORS headers to support direct uploads from the Ghost admin interface. The configuration allows all origins since the bucket itself isn't publicly accessible.
cors: [
{
allowedHeaders: ['*'],
allowedMethods: [
s3.HttpMethods.GET,
s3.HttpMethods.PUT,
s3.HttpMethods.POST,
s3.HttpMethods.DELETE,
s3.HttpMethods.HEAD,
],
allowedOrigins: ['*'],
exposedHeaders: ['ETag'],
maxAge: 3000,
},
];
CloudFront's response headers policy adds both CORS and security headers. CORS restricts access to your Ghost domain while security headers protect against common web vulnerabilities.
DNS Configuration
Route53 creates an A record pointing the images subdomain to the CloudFront distribution. The main domain's hosted zone manages this record.
new route53.ARecord(this, 'ImagesARecord', {
zone: hostedZone,
recordName: 'images',
target: route53.RecordTarget.fromAlias(
new route53Targets.CloudFrontTarget(this.imagesDistribution),
),
});
Migration Considerations
Ghost stores absolute URLs for all images in its database, not relative paths. This becomes painfully clear when you migrate domains and suddenly all your images return 404 errors. The image files still exist in S3, CloudFront still serves them, but Ghost's database points to URLs that no longer exist.
We discovered this after migrating from an old domain to our current one. The site loaded fine, the text appeared correctly, but every image was broken. The posts table in Ghost's database contained hundreds of references to https://images.olddomain.com/content/images/...
when they needed to point to https://images.newdomain.com/content/images/...
.
The fix requires updating the database directly. First, create a backup of your RDS cluster because one typo in an UPDATE statement can corrupt all your image URLs:
aws rds create-db-cluster-snapshot \
--db-cluster-snapshot-identifier ghost-backup-$(date +%Y%m%d) \
--db-cluster-identifier your-cluster-name
Then connect to the database and update the URLs. Ghost stores image references in multiple places: the feature_image column contains cover images, the html column contains the rendered content with image tags, and the mobiledoc column contains Ghost's internal JSON representation of the post:
-- Update feature images
UPDATE posts
SET feature_image = REPLACE(feature_image,
'https://images.olddomain.com/',
'https://images.newdomain.com/')
WHERE feature_image LIKE '%images.olddomain.com%';
-- Update content images in HTML
UPDATE posts
SET html = REPLACE(html,
'https://images.olddomain.com/',
'https://images.newdomain.com/')
WHERE html LIKE '%images.olddomain.com%';
-- Update mobiledoc JSON content
UPDATE posts
SET mobiledoc = REPLACE(mobiledoc,
'https://images.olddomain.com/',
'https://images.newdomain.com/')
WHERE mobiledoc LIKE '%images.olddomain.com%';
If you're migrating from local file storage to S3, the process is similar but you'll also need to upload the images first:
# Upload existing images to S3
aws s3 sync ./content/images s3://your-bucket/content/images/ \
--acl private \
--cache-control "public, max-age=31536000"
# Update database to use CloudFront URLs instead of local paths
UPDATE posts
SET feature_image = REPLACE(feature_image,
'/content/images/',
'https://images.yourdomain.com/content/images/')
WHERE feature_image LIKE '/content/images/%';
This absolute URL storage is a design decision in Ghost that makes sense for a CMS that might be accessed from multiple domains or subdomains, but it means any domain or protocol change requires database surgery. Always test these updates on a staging environment first, and always keep that backup handy.
Next Steps
With storage configured, Ghost can reliably handle image uploads in a containerized environment. The next post in this series covers email configuration with Mailgun for member communications and transactional emails.