Deploying a static website to AWS with an external domain using the CDK
Jake Langford
Posted on December 8, 2021
The why
AWS makes it really convenient when it comes to building scalable, easily deployable, applications. Especially when it's supported by strong documentation. I find the problems start to arise when you leave the amazon ecosystem. The documentation falls apart.
I experienced that this week when I wanted to deploy a static website to S3 but use a domain that I registered elsewhere. There are countless tutorials and docs on how to do it if your domain is registered with amazon, but I couldn't find much for my scenario. Hopefully I can fix that.
Tutorial
Getting Started
TLDR? Scroll down to the end of the article to see the finished code example
For this tutorial, I'll assume you are familiar with the CDK and Typescript, although I'm sure the concepts apply to other tools. For the sake of this tutorial, I'll be building a single CDK stack with everything we need.
It's important to know that your stack MUST be in us-east-1
as this is where Cloudfront looks for certificates.
Here's what we'll start with:
Be sure to check the comments in the code examples
import { Stack, StackProps, Aws } from 'aws-cdk-lib';
const domainName = 'example.com';
export class TutorialStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
// Our stack contents will go here
}
}
Configuring S3
The first thing we need to add to our stack is somewhere to put your website. For that we'll need to create an s3 bucket.
// top of file:
import { Bucket, BlockPublicAccess } from 'aws-cdk-lib/aws-s3';
// in the stack:
const bucket = new Bucket(this, 'SiteBucket', {
bucketName: domainName, // bucket name MUST be the domain name
websiteIndexDocument: 'index.html', // your sites main page
websiteErrorDocument: 'index.html', // for simplicity
publicReadAccess: false, // we'll use Cloudfront to access
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});
Serving it up
Certificate
To serve our website securely, we'll need to request a certificate from amazon for our domain using the certificate module.
// top of file:
import { Certificate, CertificateValidation } from 'aws-cdk-lib/aws-certificatemanager';
// in the stack:
const cert = new Certificate(this, 'Certificate', {
domainName: domainName,
validation: CertificateValidation.fromDns(),
});
Note: The first time you deploy your stack, it will appear to hang on creation of the certificate because you need to prove you own the domain (Choose option B). Don't worry if the deployment fails. Just rerun it when you're ready.
Cloudfront
So we've got our website in the bucket and we have the certificate to securely serve it up. Let's get it up and running.
Firstly, we need to create a special user to allow Cloudfront to access the bucket we made earlier. We'll use the Origin Access Identity Module and assign it to the bucket.
// top of file: (we'll use a lot of these imports later)
import { CloudFrontAllowedMethods, CloudFrontWebDistribution,
OriginAccessIdentity, SecurityPolicyProtocol,
SSLMethod, ViewerCertificate } from 'aws-cdk-lib/aws-cloudfront';
import { CanonicalUserPrincipal, PolicyStatement }
from 'aws-cdk-lib/aws-iam';
// in the stack:
const cloudfrontOAI = new OriginAccessIdentity(this,
'CloudfrontOAI',
{comment: `Cloudfront OAI for ${domainName}`},
);
bucket.addToResourcePolicy(new PolicyStatement({
actions: ['s3:GetObject'],
resources: [bucket.arnForObjects('*')],
principals: [
new CanonicalUserPrincipal(
cloudfrontOAI
.cloudFrontOriginAccessIdentityS3CanonicalUserId
)
],
}));
Next up we're ready to configure the actual Cloudfront dsitribution. We'll need to tell it to use the certificate we made earlier, so we'll do that here too.
// top of file:
import { Metric } from 'aws-cdk-lib/aws-cloudwatch';
// we imported the rest of the things we need in the last step
// in the stack:
const viewerCert = ViewerCertificate.fromAcmCertificate({
certificateArn: cert.certificateArn,
env: {
region: Aws.REGION,
account: Aws.ACCOUNT_ID,
},
node: this.node,
stack: this,
metricDaysToExpiry: () => new Metric({
namespace: 'TLS viewer certificate validity',
metricName: 'TLS Viewer Certificate expired',
})
},
{
sslMethod: SSLMethod.SNI,
securityPolicy: SecurityPolicyProtocol.TLS_V1_1_2016,
aliases: [domainName],
});
const distribution = new CloudFrontWebDistribution(this,
'SiteDistribution', {
viewerCertificate: viewerCert,
originConfigs: [
{
s3OriginSource: {
s3BucketSource: bucket,
originAccessIdentity: cloudfrontOAI
},
behaviors: [{
isDefaultBehavior: true,
compress: true,
allowedMethods:
CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
}],
}
]
});
Deploying your site
So now we have somewhere to put our website and a way to serve it up, we need to actually put it there. We'll use the Bucket Deployment module. This module takes your files and stores them in our bucket during CDK deployment.
// top of file:
import { BucketDeployment, Source } from 'aws-cdk-lib/aws-s3-deployment';
// in the stack:
new BucketDeployment(this, 'Website Deployment', {
// Here goes the path to your website files.
// The path is relative to the root folder of your CDK app.
sources: [Source.asset('../ui/build')],
destinationBucket: bucket,
distribution,
distributionPaths: ['/*'],
});
Domain
So that's it for the AWS side of things. Go ahead and deploy. But we haven't pointed our domain name to our Cloudfront distribution. Unfortunately, this will be different depending on your registrar, but I'll help as best I can.
In essence, you'll need to create a CNAME record and point your domain at the cloudfront url. You can find that by going to the Cloudfront control panel and finding your distribution domain as seen below.
Here's what that looks like with my registrar:
Conclusion
That should be your lot. If you've followed the steps here, you should be able to securely visit your website with your custom domain name.
Please let me know if I've made any mistakes or if I can improve this guide in any way.
So to recap, We:
- Created a bucket to store the site in.
- Created a certificate to serve the site over https.
- We proved to AWS we own the domain.
- Created a special user to access the site.
- Created a viewer certificate so Cloudfront can use the certificate we made before.
- Created our Cloudfront distribution.
- Deployed our site to s3.
- Made a CNAME record with our registrar and pointed it at out Cloudfront domain.
Complete Code
import { Stack, StackProps, Aws } from "aws-cdk-lib";
import { Construct } from "constructs";
import { Bucket, BlockPublicAccess } from "aws-cdk-lib/aws-s3";
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
import {
Certificate,
CertificateValidation,
} from "aws-cdk-lib/aws-certificatemanager";
import {
CloudFrontAllowedMethods,
CloudFrontWebDistribution,
OriginAccessIdentity,
SecurityPolicyProtocol,
SSLMethod,
ViewerCertificate,
} from "aws-cdk-lib/aws-cloudfront";
import { CanonicalUserPrincipal, PolicyStatement } from "aws-cdk-lib/aws-iam";
import { Metric } from "aws-cdk-lib/aws-cloudwatch";
const domainName = "example.com";
export class TutorialStack extends Stack {
constructor(scope: Construct, id: string, props: StackProps) {
super(scope, id, props);
const bucket = new Bucket(this, "SiteBucket", {
bucketName: domainName,
websiteIndexDocument: "index.html",
websiteErrorDocument: "index.html",
publicReadAccess: false,
blockPublicAccess: BlockPublicAccess.BLOCK_ALL,
});
const cert = new Certificate(this, "Certificate", {
domainName: domainName,
validation: CertificateValidation.fromDns(),
});
const cloudfrontOAI = new OriginAccessIdentity(this, "CloudfrontOAI", {
comment: `Cloudfront OAI for ${domainName}`,
});
bucket.addToResourcePolicy(
new PolicyStatement({
actions: ["s3:GetObject"],
resources: [bucket.arnForObjects("*")],
principals: [
new CanonicalUserPrincipal(
cloudfrontOAI.cloudFrontOriginAccessIdentityS3CanonicalUserId
),
],
})
);
const viewerCert = ViewerCertificate.fromAcmCertificate(
{
certificateArn: cert.certificateArn,
env: {
region: Aws.REGION,
account: Aws.ACCOUNT_ID,
},
node: this.node,
stack: this,
metricDaysToExpiry: () =>
new Metric({
namespace: "TLS viewer certificate validity",
metricName: "TLS Viewer Certificate expired",
}),
},
{
sslMethod: SSLMethod.SNI,
securityPolicy: SecurityPolicyProtocol.TLS_V1_1_2016,
aliases: [domainName],
}
);
const distribution = new CloudFrontWebDistribution(
this,
"SiteDistribution",
{
viewerCertificate: viewerCert,
originConfigs: [
{
s3OriginSource: {
s3BucketSource: bucket,
originAccessIdentity: cloudfrontOAI,
},
behaviors: [
{
isDefaultBehavior: true,
compress: true,
allowedMethods: CloudFrontAllowedMethods.GET_HEAD_OPTIONS,
},
],
},
],
}
);
new BucketDeployment(this, "DeploySite", {
sources: [Source.asset("../ui/build")],
destinationBucket: bucket,
distribution,
distributionPaths: ["/*"],
});
}
}
Posted on December 8, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.