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),
});
}
}