Using CDK to set up a static website in AWS

Hosting a static website with S3 and Cloudfront in 2024

Clouds over mountains

Using CDK to set up a static website in AWS

Hosting a static website with S3 and Cloudfront in 2024

Setting up a static website in AWS is, in theory, very simple, and CDK makes deployment trivial. However, there are a few details that will frustrate efforts to get the job done.

We’ll be setting up a static website by hosting the source files in S3. A website served out of S3 can only be on HTTP, so we’ll need Cloudfront to handle secure connections with end users. We’ll also set up a pipeline for automated deployment of updates, and assign a domain name from Route53.

A note on regions

Cloudfront only supports SSL certificates that exist in the us-east-1 region. For that reason, it’s easiest to deploy all resources in that region. Since static websites are very simple and don’t generally hold sensitive/protected information, this should be fine, but check at the beginning of the project to make sure that the site doesn’t need to be hosted in a specific region.

Setup

We need a stack for our static website, and a prop definition for reusable parameters.

export interface StaticWebsiteProps extends StackProps {
  // Name or unique identifier for the site, without spaces.
  siteName: string;
  // An array of domain names to put on the SSL certificate.
  domainNames: string[];
  // The name of the hosted zone which holds the domain in Route53.
  zoneName: string;
  // The name of the CodeCommit repository that holds the website source code.
  sourceRepositoryName: string;
}

export class StaticWebsiteStack extends Stack {
  constructor(scope: Construct, id: string, props: StaticWebsiteProps) {
    super(scope, id, props);
  }
}

We’ll also need to create a CodeCommit repository to hold the website source code. This repository needs to be in the same region as the bucket and distribution, so we make sure it exists in the us-east-1 region.

Creating an S3 bucket

The bucket parameters are simple. The important thing is setting autoDeleteObjects and removalPolicy. Without them, the bucket can’t be deleted, which means taking the stack down with cdk destroy will fail.

private createSourceBucket(domainName: string): Bucket {
  return new Bucket(this, `WebsiteSourceFiles`, {
    autoDeleteObjects: true,
    bucketName: `${domainName}-source-files`,
    publicReadAccess: false,
    removalPolicy: RemovalPolicy.DESTROY,
  });
}

Pass in the site name:

const sourceBucket = this.createSourceBucket(props.siteName);

Requesting an SSL certificate

We need the hosted zone from Route53 for certificate validation, so we’ll query that and pass it to the aws-certificatemanager.Certificate construct.

private getHostedZone(domainName: string): IHostedZone {
  return HostedZone.fromLookup(this, `HostedZone-${domainName}`, {
    domainName,
  });
}

private createSslCertificate(
  domainName: string,
  subjectAlternativeNames: string[],
  hostedZone: IHostedZone,
): Certificate {
  return new Certificate(this, 'SiteCertificate', {
    certificateName: `${domainName}-site-certificate`,
    domainName: domainName,
    subjectAlternativeNames: subjectAlternativeNames,
    validation: CertificateValidation.fromDns(hostedZone),
  });
}
const dnsZone = this.getHostedZone(props.zoneName);
const sslCertificate = this.createSslCertificate(
  props.domainNames[0],
  props.domainNames.slice(1, props.domainNames.length),
  dnsZone,
);

Configuring the Cloudfront Distribution

We take the bucket and certificate we just created, and pass them to Cloudfront. This website is read-only, so we’ll only need to allow GET/HEAD requests, and since we’ve gone to the trouble of generating a certificate, we’ll want to force HTTPS.

A note about index files

With Apache or nginx webservers, we’d configure an index file to be shown when a directory is accessed. This would be index.html by default. We can’t configure Cloudfront in the same way, which means a URL like /blog will trigger an S3 error. To resolve this issue, we add a Function to the distribution that will append index.html to the end of any directory requests.

  private createCloudFrontWebDistribution(
    sourceBucket: Bucket,
    sslCertificate: Certificate,
    domains: string[],
): CloudFrontWebDistribution {
  const appendFunction = new Function(this, 'AppendIndexHtmlToDirectory', {
    functionName: 'AppendIndexHtmlToDirectory',
    // @see https://github.com/aws-samples/amazon-cloudfront-functions/blob/main/url-rewrite-single-page-apps/index.js
    code: aws_cloudfront.FunctionCode.fromInline(`
        function handler(event) {
          var request = event.request;

          if (request.uri.endsWith('/')) {
            request.uri += 'index.html';
          } else if (!request.uri.includes('.')) {
            request.uri += '/index.html';
          }

          return request;
        }
      `),
  });

  return new CloudFrontWebDistribution(this, 'Cloudfront', {
    originConfigs: [
      {
        s3OriginSource: {
          s3BucketSource: sourceBucket,
        },
        behaviors: [
          {
            allowedMethods: CloudFrontAllowedMethods.GET_HEAD,
            cachedMethods: CloudFrontAllowedCachedMethods.GET_HEAD,
            compress: true,
            functionAssociations: [
              {
                function: appendFunction,
                eventType: FunctionEventType.VIEWER_REQUEST,
              },
            ],
            isDefaultBehavior: true,
            viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
          },
        ],
      },
    ],

    viewerCertificate: {
      aliases: domains,
      props: {
        acmCertificateArn: sslCertificate.certificateArn,
        minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
        sslSupportMethod: SSLMethod.SNI,
      },
    },
  });
}

The CloudFrontWebDistribution construct automatically attaches an Origin Access Identity. OAI is deprecated and so we’ll want to attach the newer Origin Access Control.

private createOriginAccessControl(oacName: string): CfnOriginAccessControl {
  return new CfnOriginAccessControl(this, 'WebsiteOriginAccessControl', {
    originAccessControlConfig: {
      name: `${oacName}-access-control`,
      originAccessControlOriginType: 's3',
      signingBehavior: 'always',
      signingProtocol: 'sigv4',
    },
  });
}

At the time of development, CDK does not support attaching an OAC to a distribution via the CloudFrontWebDistribution construct, so we’ll implement the workaround described in this GitHub issue to manually override the property in the generated CloudFormation template.

const cdn = this.createCloudFrontWebDistribution(
  sourceBucket,
  sslCertificate,
  props.domainNames,
);

const originAccessControl = this.createOriginAccessControl(props.siteName);

const cfnDistribution = cdn.node.defaultChild as CfnDistribution;
cfnDistribution.addPropertyOverride(
  'DistributionConfig.Origins.0.OriginAccessControlId',
  originAccessControl.getAtt('Id'),
);

The same day that this post is being written, a new construct has been published in the AWS Solutions Constructs package that purports to resolve this issue in a cleaner way.

Setting up the deployment pipeline

We want to set up a CodePipeline so that any changes to the source repository will automatically be deployed. This will be simple - a trigger on commit will start the pipeline, which will check out the latest version of the code and copy it to the S3 bucket.

private createDeploymentPipeline(
  siteName: string, 
  repository: IRepository, 
  siteBucket: Bucket,
) {
  const sourceCodeArtifact = new Artifact(`${siteName}-source-artifact`);

  return new Pipeline(this, 'DeploymentPipeline', {
    pipelineName: `${siteName}-deployment-pipeline`,
    crossAccountKeys: false,
    stages: [
      {
        stageName: 'Source',
        actions: [
          new CodeCommitSourceAction({
            actionName: 'Source',
            output: sourceCodeArtifact,
            repository,
            trigger: CodeCommitTrigger.EVENTS,
          }),
        ],
      },
      {
        stageName: 'Deploy',
        actions: [
          new S3DeployAction({
            actionName: 'Deploy',
            bucket: siteBucket,
            input: compiledAssetsArtifact,
          }),
        ],
      },
    ],
  });
}
const repository = Repository.fromRepositoryName(this, 'SourceRepository',
  props.sourceRepositoryName,
);
this.createDeploymentPipeline(props.siteName, repository, sourceBucket);

Setting the bucket access policy

CloudFront will need permission to list and get objects from the bucket, so we’ll attach a resource policy.

private setAccessPolicyOnBucket(
  sourceBucket: Bucket,
  cdn: CloudFrontWebDistribution,
): AddToResourcePolicyResult {
  return sourceBucket.addToResourcePolicy(new PolicyStatement({
    actions: ['s3:GetObject', 's3:ListBucket'],
    effect: Effect.ALLOW,
    resources: [sourceBucket.bucketArn, sourceBucket.arnForObjects('*')],
    principals: [new ServicePrincipal('cloudfront.amazonaws.com')],
    conditions: {
      StringEquals: {
        'aws:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${cdn.distributionId}`,
      },
    },
  }));
}
this.setAccessPolicyOnBucket(sourceBucket, cdn);

Updating the DNS

Finally, we’ll want to update the DNS in Route53 to point our domain at the new CloudFront Distribution.

private createDnsRecord(
  recordName: string, 
  cdn: CloudFrontWebDistribution,
  zone: IHostedZone,
): ARecord {
  return new ARecord(this, `SiteAlias-${recordName}`, {
    recordName,
    target: RecordTarget.fromAlias(new CloudFrontTarget(cdn)),
    zone,
  });
}
props.domainNames.forEach((domainName) => {
  this.createDnsRecord(domainName, cdn, dnsZone);
});

Deploying the stack

We’ll add the code to our CDK entry script to create the stack with our desired parameters. The important thing to remember is setting the region to us-east-1.

#!/usr/bin/env node
import * as cdk from "aws-cdk-lib";
import { Tags } from "aws-cdk-lib";
import 'source-map-support/register';
import { StaticWebsiteStack } from "../lib/static-website-stack";

const app = new cdk.App();

const clWebsite = new StaticWebsiteStack(app, 'CygnusloopconsultingCoUk', {
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: 'us-east-1',
  },
  siteName: 'cygnusloopconsulting-co-uk',
  zoneName: 'cygnusloopconsulting.co.uk',
  domainNames: ['cygnusloopconsulting.co.uk', 'www.cygnusloopconsulting.co.uk'],
  sourceRepositoryName: 'cl-website',
});

Tags.of(clWebsite).add('Project', 'cl-website');

Then it’s just a case of running cdk deploy to create the site in AWS.

Full contents of static-website-stack.ts

import {RemovalPolicy, Stack, StackProps} from 'aws-cdk-lib';
import {
  Certificate,
  CertificateValidation,
} from 'aws-cdk-lib/aws-certificatemanager';
import {
  CfnDistribution,
  CfnOriginAccessControl,
  CloudFrontAllowedCachedMethods,
  CloudFrontAllowedMethods,
  CloudFrontWebDistribution,
  ViewerProtocolPolicy,
} from 'aws-cdk-lib/aws-cloudfront';
import {
  BuildSpec,
  LinuxBuildImage,
  PipelineProject,
} from 'aws-cdk-lib/aws-codebuild';
import {IRepository, Repository} from 'aws-cdk-lib/aws-codecommit';
import {Artifact, Pipeline} from 'aws-cdk-lib/aws-codepipeline';
import {
  CodeBuildAction,
  CodeBuildActionType,
  CodeCommitSourceAction,
  CodeCommitTrigger,
  S3DeployAction,
} from 'aws-cdk-lib/aws-codepipeline-actions';
import {
  AddToResourcePolicyResult,
  Effect,
  PolicyStatement,
  ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import {
  ARecord,
  HostedZone,
  IHostedZone,
  RecordTarget,
} from 'aws-cdk-lib/aws-route53';
import {CloudFrontTarget} from 'aws-cdk-lib/aws-route53-targets';
import {Bucket} from 'aws-cdk-lib/aws-s3';
import {Construct} from 'constructs';

export interface StaticWebsiteProps extends StackProps {
  siteName: string;
  zoneName: string;
  domainNames: string[];
  // Must be in same region.
  sourceRepositoryName: string;
}

export class StaticWebsiteStack extends Stack {
  constructor(scope: Construct, id: string, props: StaticWebsiteProps) {
    super(scope, id, props);

    const dnsZone        = this.getHostedZone(props.zoneName);
    const sslCertificate = this.createSslCertificate(
        props.domainNames[0],
        props.domainNames.slice(1, props.domainNames.length),
        dnsZone,
    );

    const sourceBucket = this.createSourceBucket(props.siteName);

    const cdn = this.createCloudFrontWebDistribution(
        sourceBucket,
        sslCertificate,
        props.domainNames,
    );

    const originAccessControl = this.createOriginAccessControl(props.siteName);

    // Workaround to attach Origin Access Control to the CDN as the CDK doesn't
    // support it at present.
    const cfnDistribution = cdn.node.defaultChild as CfnDistribution;
    cfnDistribution.addPropertyOverride(
        'DistributionConfig.Origins.0.OriginAccessControlId',
        originAccessControl.getAtt('Id'),
    );

    const repository = Repository.fromRepositoryName(this, 'SourceRepository',
        props.sourceRepositoryName,
    );
    this.createDeploymentPipeline(props.siteName, repository, sourceBucket);

    this.setAccessPolicyOnBucket(sourceBucket, cdn);
    props.domainNames.forEach((domainName) => {
      this.createDnsRecord(domainName, cdn, dnsZone);
    });
  }

  private createDnsRecord(
      recordName: string,
      cdn: CloudFrontWebDistribution,
      zone: IHostedZone,
  ): ARecord {
    return new ARecord(this, `SiteAlias-${recordName}`, {
      recordName,
      target: RecordTarget.fromAlias(new CloudFrontTarget(cdn)),
      zone,
    });
  }

  private getHostedZone(domainName: string): IHostedZone {
    return HostedZone.fromLookup(this, `HostedZone-${domainName}`, {
      domainName,
    });
  }

  private setAccessPolicyOnBucket(
      sourceBucket: Bucket,
      cdn: CloudFrontWebDistribution,
  ): AddToResourcePolicyResult {
    return sourceBucket.addToResourcePolicy(new PolicyStatement({
      actions: ['s3:GetObject', 's3:ListBucket'],
      effect: Effect.ALLOW,
      resources: [sourceBucket.bucketArn, sourceBucket.arnForObjects('*')],
      principals: [new ServicePrincipal('cloudfront.amazonaws.com')],
      conditions: {
        StringEquals: {
          'aws:SourceArn': `arn:aws:cloudfront::${this.account}:distribution/${cdn.distributionId}`,
        },
      },
    }));
  }

  private createDeploymentPipeline(
      siteName: string,
      repository: IRepository,
      siteBucket: Bucket,
  ) {
    const sourceCodeArtifact     = new Artifact(`${siteName}-source-artifact`);
    const compiledAssetsArtifact = new Artifact(
        `${siteName}-compiled-assets-artifact`);

    return new Pipeline(this, 'DeploymentPipeline', {
      pipelineName: `${siteName}-deployment-pipeline`,
      crossAccountKeys: false,
      stages: [
        {
          stageName: 'Source',
          actions: [
            new CodeCommitSourceAction({
              actionName: 'Source',
              output: sourceCodeArtifact,
              repository,
              trigger: CodeCommitTrigger.EVENTS,
            }),
          ],
        },
        {
          stageName: 'Deploy',
          actions: [
            new S3DeployAction({
              actionName: 'Deploy',
              bucket: siteBucket,
              input: compiledAssetsArtifact,
            }),
          ],
        },
      ],
    });
  }

  private createCloudFrontWebDistribution(
      sourceBucket: Bucket,
      sslCertificate: Certificate,
      domains: string[],
  ): CloudFrontWebDistribution {
    const appendFunction = new Function(this, 'AppendIndexHtmlToDirectory', {
      functionName: 'AppendIndexHtmlToDirectory',
      // @see https://github.com/aws-samples/amazon-cloudfront-functions/blob/main/url-rewrite-single-page-apps/index.js
      code: aws_cloudfront.FunctionCode.fromInline(`
        function handler(event) {
          var request = event.request;

          if (request.uri.endsWith('/')) {
            request.uri += 'index.html';
          } else if (!request.uri.includes('.')) {
            request.uri += '/index.html';
          }

          return request;
        }
      `),
    });

    return new CloudFrontWebDistribution(this, 'Cloudfront', {
      originConfigs: [
        {
          s3OriginSource: {
            s3BucketSource: sourceBucket,
          },
          behaviors: [
            {
              allowedMethods: CloudFrontAllowedMethods.GET_HEAD,
              cachedMethods: CloudFrontAllowedCachedMethods.GET_HEAD,
              compress: true,
              functionAssociations: [
                {
                  function: appendFunction,
                  eventType: FunctionEventType.VIEWER_REQUEST,
                },
              ],
              isDefaultBehavior: true,
              viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
            },
          ],
        },
      ],

      viewerCertificate: {
        aliases: domains,
        props: {
          acmCertificateArn: sslCertificate.certificateArn,
          minimumProtocolVersion: SecurityPolicyProtocol.TLS_V1_2_2021,
          sslSupportMethod: SSLMethod.SNI,
        },
      },
    });
  }

  private createOriginAccessControl(oacName: string): CfnOriginAccessControl {
    return new CfnOriginAccessControl(this, 'WebsiteOriginAccessControl', {
      originAccessControlConfig: {
        name: `${oacName}-access-control`,
        originAccessControlOriginType: 's3',
        signingBehavior: 'always',
        signingProtocol: 'sigv4',
      },
    });
  }

  private createSourceBucket(domainName: string): Bucket {
    return new Bucket(this, `WebsiteSourceFiles`, {
      autoDeleteObjects: true,
      bucketName: `${domainName}-source-files`,
      publicReadAccess: false,
      removalPolicy: RemovalPolicy.DESTROY,
    });
  }

  private createSslCertificate(
      domainName: string,
      subjectAlternativeNames: string[],
      hostedZone: IHostedZone,
  ): Certificate {
    return new Certificate(this, 'SiteCertificate', {
      certificateName: `${domainName}-site-certificate`,
      domainName: domainName,
      subjectAlternativeNames: subjectAlternativeNames,
      validation: CertificateValidation.fromDns(hostedZone),
    });
  }
}

See also