Introduction Link to heading
We’ve covered a bunch of other ground, but now it’s time to actually deploy the static site.
A Static Site Link to heading
Referring back to our diagram, it’s clear that the first thing after the CodeBuild, which we are doing separately, is an S3 bucket.
export class StaticSite extends cdk.Construct {
constructor(scope: cdk.Construct, id: string) {
super(scope, id);
const bucket = new s3.Bucket(this, 'Bucket', {});
}
}
This gets our first layer of boiler-plate out of the way.
We’ve declared StaticSite as a class and based it on the cdk.Construct.
Then we declared an S3 bucket. With no properties.
Ok, but… S3 buckets are more useful when they have names.
And… our website is going to have a name.
S3 bucket names are required to be globally unique,
and dns names are by definition globally unique.
Let’s use the name of the website for the name of the bucket.
This has the added benefit of improving the discoverability of the system
since you can pretty quickly associate the DNS and the S3 bucket
when the have the same name.
In order to get this name, we’re going to need to accept some properties. Because we intend to deploy this as a web site, it makes sense to split up the name into a dns zone (the domain name part of the address) and a record. The properties will get passed into the stack via an Interface.
export interface StaticSiteProps {
/**
* The name of your site: "myblog".mydomain.com
*
* Obviously this needs to be lower case and no special characters.
* See RFC-1034 and RFC-1035 for details.
*/
readonly siteName: string;
/**
* The domain of your blog: mydomain.com
* This domain name MUST be part of a zone managed by AWS Route53.
* It's easiest to just use AWS as your registrar,
* but either way the zone has to be in AWS Route53 for it to work.
*/
readonly siteDomain: string;
}
export class StaticSite extends cdk.Construct {
constructor(scope: cdk.Construct, id: string, props: StaticSiteProps) {
super(scope, id);
const fqdn = `${props.siteName}.${props.siteDomain}`;
const bucket = new s3.Bucket(this, 'Bucket', {
bucketName: fqdn,
encryption: s3.BucketEncryption.S3_MANAGED, // https://aws.amazon.com/premiumsupport/knowledge-center/s3-website-cloudfront-error-403/
removalPolicy: cdk.RemovalPolicy.DESTROY,
websiteIndexDocument: 'index.html',
websiteErrorDocument: '404.html',
});
new cdk.CfnOutput(this, 'TheBucket', { value: bucket.bucketArn });
Now we have a construct which allows configuration of the name. We’re creating a bucket and using that name. Because the bucket is basically a cache of the rendered Hugo output, we will keep our lives easy by setting a removal policy of destroy, so that cdk can clean up the bucket when and if we are destroy the project.
We’ve also set some website basics for the bucket and, interestingly, an encryption policy. But… why encrypt a bucket which is only used to serve static content to the public internet? If you only ever intend to use this stuff for personal use, then you don’t really need to bother. However if you intend to use this in a setting where some form of regulations might apply (looking at you, HIPAA and PHIPA), it’s really great to be able to assert that everything, yes, everything, is encrypted at rest.
We’re also about to limit access to this S3 bucket to only the CloudFront distribution which is supposed to be actually serving the content. Why? Well, because I don’t want my site to be a leaky abstraction. I want readers getting their content from a globally scalable, rock solid CDN. And I also don’t want to expose my bucket as a single point of failure or DDOS target. Finally, because it’s not the 90’s any more, we’re going to encrypt communications with the site, and use an SSL certificate to prove it’s identity.
Ok, here’s the code you were waiting for:
export class StaticSite extends cdk.Construct {
public readonly bucket: aws_s3.Bucket;
constructor(scope: Construct, id: string, props: StaticSiteProps) {
super(scope, id);
const fqdn = `${props.siteName}.${props.siteDomain}`;
const zone = aws_route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.siteDomain });
const certificate = new aws_certificatemanager.DnsValidatedCertificate(this, 'Certificate', {
domainName: fqdn,
hostedZone: zone,
});
// Provide an identity for CloudFront to use to access the S3 bucket.
const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {
comment: `OAI for ${fqdn}`,
});
this.bucket = new aws_s3.Bucket(this, 'Bucket', {
bucketName: fqdn,
encryption: aws_s3.BucketEncryption.S3_MANAGED, // https://aws.amazon.com/premiumsupport/knowledge-center/s3-website-cloudfront-error-403/
removalPolicy: RemovalPolicy.DESTROY,
accessControl: aws_s3.BucketAccessControl.PRIVATE,
websiteIndexDocument: 'index.html',
websiteErrorDocument: '404.html',
});
// Grant the CloudFront OAI access to the bucket.
this.bucket.addToResourcePolicy(
new aws_iam.PolicyStatement({
actions: ['s3:GetBucket*', 's3:GetObject*', 's3:List*'],
resources: [this.bucket.bucketArn, `${this.bucket.bucketArn}/*`],
principals: [
new aws_iam.CanonicalUserPrincipal(originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId),
],
}),
);
Ok, there’s only a few things left to do. We need to add a bucket for log files. We need to add the actual CloudFront Distribution. And we need to set up DNS for the Distribution.
export class StaticSite extends Construct implements IStaticSite {
public readonly bucket: aws_s3.Bucket;
public readonly distribution: aws_cloudfront.Distribution;
constructor(scope: Construct, id: string, props: StaticSiteProps) {
super(scope, id);
const fqdn = `${props.siteName}.${props.siteDomain}`;
const zone = aws_route53.HostedZone.fromLookup(this, 'Zone', { domainName: props.siteDomain });
const certificate = new aws_certificatemanager.DnsValidatedCertificate(this, 'Certificate', {
domainName: fqdn,
hostedZone: zone,
});
const logBucket = new aws_s3.Bucket(this, 'LogBucket', {
bucketName: `${fqdn}-logs`,
encryption: aws_s3.BucketEncryption.S3_MANAGED,
removalPolicy: RemovalPolicy.DESTROY,
accessControl: aws_s3.BucketAccessControl.PRIVATE,
lifecycleRules: [
{
expiration: Duration.days(10), // Do you really want to keep logs for longer than 10 days?
},
],
});
// Provide an identity for CloudFront to use to access the S3 bucket.
const originAccessIdentity = new aws_cloudfront.OriginAccessIdentity(this, 'OriginAccessIdentity', {
comment: `OAI for ${fqdn}`,
});
this.bucket = new aws_s3.Bucket(this, 'Bucket', {
bucketName: fqdn,
encryption: aws_s3.BucketEncryption.S3_MANAGED, // https://aws.amazon.com/premiumsupport/knowledge-center/s3-website-cloudfront-error-403/
removalPolicy: RemovalPolicy.DESTROY,
accessControl: aws_s3.BucketAccessControl.PRIVATE,
websiteIndexDocument: 'index.html',
websiteErrorDocument: '404.html',
});
// Grant the CloudFront OAI access to the bucket.
this.bucket.addToResourcePolicy(
new aws_iam.PolicyStatement({
actions: ['s3:GetBucket*', 's3:GetObject*', 's3:List*'],
resources: [this.bucket.bucketArn, `${this.bucket.bucketArn}/*`],
principals: [
new aws_iam.CanonicalUserPrincipal(originAccessIdentity.cloudFrontOriginAccessIdentityS3CanonicalUserId),
],
}),
);
this.distribution = new aws_cloudfront.Distribution(this, 'Distribution', {
certificate,
defaultBehavior: {
origin: new aws_cloudfront_origins.S3Origin(this.bucket, {
originAccessIdentity,
}),
edgeLambdas: [
{
functionVersion: props.redirectFn,
eventType: aws_cloudfront.LambdaEdgeEventType.ORIGIN_REQUEST,
},
],
},
domainNames: [fqdn],
errorResponses: [
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: '/404.html',
},
],
logBucket,
priceClass: aws_cloudfront.PriceClass.PRICE_CLASS_100,
});
// Route53 alias record for the CloudFront distribution
new aws_route53.ARecord(this, 'SiteAlias', {
target: aws_route53.RecordTarget.fromAlias(new aws_route53_targets.CloudFrontTarget(this.distribution)),
zone: zone,
recordName: fqdn,
});
}
}
A couple of things really stand out.
First of all, once you’ve declared the OAI, it’s a single line of code to instruct the Distribution to use it.
Second, getting the Distribution to call the Edge lambda is absolutely trivial.
Third, the default priceClass is global and hence the most expensive.
Your content is probably more relevant than mine, but I’m guessing that my readers will mostly be in North America.
Conclusion Link to heading
That’s… it. A fully modern static website deployed in AWS using only about 80 lines of very human-readable and understandable code.
This is why I find aws-cdk so exciting to work with!