Antoine Lehurt

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.”