CDK pattern – Caching static assets with AWS S3 and CloudFront

July 30, 2021

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"
cache-control: public, max-age=31536000, immutableserver: AmazonS3
content-encoding: gzip
vary: Accept-Encoding
x-cache: Hit from cloudfront

Using Lighthouse, we can also verify that we don’t get any warning related to “serving static assets with an efficient cache policy.”

Feel free to send feedback or ask questions on Twitter.



Written by Antoine Lehurt (a.k.a kewah). Senior front-end engineer living in Stockholm and currently working at Acast.