CDK pattern – Caching static assets with AWS S3 and CloudFront
The code below is a pattern for client-side applications or for serving static files. We want to serve files through a CDN and cache them for an extended period.
In the AWS ecosystem, we need to store the files in S3 and set the cache-control
header to tell the CloudFront distribution how long it should cache the file.
It’s recommended to include a hash in the filename, for instance, using [contenthash]
in webpack, so we only update changed files and prevent locking the user in an old version.
import * as acm from '@aws-cdk/aws-certificatemanager';
import * as cloudfront from '@aws-cdk/aws-cloudfront';
import * as iam from '@aws-cdk/aws-iam';
import * as route53 from '@aws-cdk/aws-route53';
import * as targets from '@aws-cdk/aws-route53-targets';
import * as s3 from '@aws-cdk/aws-s3';
import * as s3Deploy from '@aws-cdk/aws-s3-deployment';
import { CacheControl } from '@aws-cdk/aws-s3-deployment';
import * as cdk from '@aws-cdk/core';
import { Duration } from '@aws-cdk/core';
import config from 'config';
export class StaticFilesStack extends cdk.Stack {
constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
const cdnDomain = config.get('domain');
new cdk.CfnOutput(this, 'Site', { value: `https://${cdnDomain}` });
const cloudfrontOAI = new cloudfront.OriginAccessIdentity(this, 'cloudfront-OAI', {
comment: `OAI for ${cdnDomain}`,
});
const siteBucket = new s3.Bucket(this, 'SiteBucket', {
// We only allow traffic to the bucket through CloudFront.
publicReadAccess: false,
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
websiteIndexDocument: 'index.html',
websiteErrorDocument: 'index.html',
});
// Grant access to CloudFront.
siteBucket.addToResourcePolicy(
new iam.PolicyStatement({
actions: ['s3:GetObject'],
resources: [siteBucket.arnForObjects('*')],
principals: [
new iam.CanonicalUserPrincipal(cloudfrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId),
],
})
);
new cdk.CfnOutput(this, 'Bucket', { value: siteBucket.bucketName });
const hostedZone = route53.HostedZone.fromLookup(this, 'hostedZone', {
domainName: config.get('domainZone'),
});
const certificate = acm.Certificate.fromCertificateArn(this, 'Certificate', config.get('certificate'));
const distribution = new cloudfront.CloudFrontWebDistribution(this, 'SiteDistribution', {
viewerCertificate: cloudfront.ViewerCertificate.fromAcmCertificate(certificate, {
aliases: [cdnDomain],
}),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
originConfigs: [
{
s3OriginSource: {
s3BucketSource: siteBucket,
originAccessIdentity: cloudfrontOAI,
},
behaviors: [{ isDefaultBehavior: true }],
},
],
});
new cdk.CfnOutput(this, 'CloudFrontUrl', {
value: `https://${distribution.distributionDomainName}`,
});
new route53.ARecord(this, 'SiteAliasRecord', {
recordName: cdnDomain,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
zone: hostedZone,
});
// We set the long cache-control header for all files except the index.html.
new s3Deploy.BucketDeployment(this, 'BucketDeploymentWithCache', {
sources: [
// `dist` is the webpack output folder.
s3Deploy.Source.asset('dist', { exclude: ['index.html'] }),
],
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'],
cacheControl: [
CacheControl.setPublic(),
CacheControl.maxAge(cdk.Duration.days(365)),
CacheControl.fromString('immutable'),
],
prune: false,
});
// Set the short cache-control header for the index.html.
// In this example I put the cache to 0 seconds, but you should adapt it to your needs.
new s3Deploy.BucketDeployment(this, 'BucketDeploymentNoCache', {
sources: [
s3Deploy.Source.asset('dist', {
exclude: ['*', '!index.html'],
}),
],
destinationBucket: siteBucket,
distribution,
distributionPaths: ['/*'],
cacheControl: [
CacheControl.setPublic(),
CacheControl.maxAge(cdk.Duration.seconds(0)),
CacheControl.sMaxAge(cdk.Duration.seconds(0)),
],
prune: false,
});
}
}
Once the stack is deployed, we can check the response header contains the correct values and hit the cache.
HTTP/2 200 OK
content-type: application/javascript
date: Fri, 30 Jul 2021 16:56:43 GMT
last-modified: Fri, 30 Jul 2021 16:56:40 GMT
etag: W/"12345"
// highlight-start
cache-control: public, max-age=31536000, immutable
// highlight-end
server: AmazonS3
content-encoding: gzip
vary: Accept-Encoding
// highlight-start
x-cache: Hit from cloudfront
// highlight-end
Using Lighthouse, we can also verify that we don’t get any warning related to “serving static assets with an efficient cache policy.”