Table of Contents
Static site generators like Hugo, Astro or even (with some concessions) Next.js are great for building responsive, SEO-friendly websites. It’s especially effective for uncomplicated landing pages, blogs or documentation sites. In AWS world, static sites have been historically hosted via an S3 bucket with website hosting enabled.
This method was superseded by the CloudFront+S3 combination
Which offers a nicer separation of concerns and more modern functionalities such as URL rewriting, SSL certificates and an actual CDN in front of it. In this post, we will create a reusable CDK construct to do just that.
Creating the private bucket
We begin by defining the properties for our static website construct. This includes the name, source directory, domain, hosted zone, SSL certificate, and removal policy. Optionally, we can also patch the root object to ensure that requests to the root URL are redirected to index.html
.
import { RemovalPolicy } from "aws-cdk-lib";
import { IHostedZone } from "aws-cdk-lib/aws-route53";
import { ICertificate } from "aws-cdk-lib/aws-certificatemanager";
export interface StaticWebsiteProps {
name: string;
source: string;
domain: string;
zone: IHostedZone;
certificate: ICertificate;
removalPolicy: RemovalPolicy;
patchRootObject?: boolean;
}
The files of our website will be copied to the bucket at the execution of our CDK stack, which is what the BucketDeployment
takes care of. The bucket itself is private by default (as it should be).
const { name, source } = props;
// Provision a private S3 bucket for static website hosting
const bucket = new Bucket(this, `${name}-static-website`, {
enforceSSL: true,
encryption: BucketEncryption.S3_MANAGED,
removalPolicy,
});
// Deploy our compiled static assets to the bucket
new BucketDeployment(this, `${name}-static-deployment`, {
destinationBucket: bucket,
sources: [Source.asset(source)],
});
Creating the CloudFront distribution
We’ll create a CloudFront distribution that acts as the CDN for our website. A CF distribution has a default behaviour characterized by:
- Origin: The upstream for the served content, in our case a private S3 bucket.
- Origin Request Policy: Defines the request sent to the upstream whenever there is a cache miss. Useful for e.g. respecting CORS policy set on the bucket level.
- Response Headers Policy: Governs the headers that are included in responses from CF. Again, useful for CORS.
- Function Associations: CloudFront Functions that may be called as part of the requests lifecycle. We’ll see how to use this to patch the root object later.
Additionally, we tell CF to default to index.html
when the root URL is requested (defaultRootObject
), and to return index.html
for 403 and 404 errors. If you have dedicated error pages, you can return them whenever a 403 or 404 error occurs.
const INDEX = "index.html";
const { name, zone, certificate, domain, patchRootObject } = props;
// Create a CloudFront distribution for the static site
const cdn = new Distribution(this, `${name}-cf-distribution`, {
domainNames: [domain],
certificate,
defaultBehavior: {
origin: S3BucketOrigin.withOriginAccessControl(bucket.bucket),
viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
originRequestPolicy: OriginRequestPolicy.CORS_S3_ORIGIN,
responseHeadersPolicy:
ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT,
functionAssociations: [
...(patchRootObject
? [
{
eventType: FunctionEventType.VIEWER_REQUEST,
function: this.patchRootObject(name),
},
]
: []),
],
},
defaultRootObject: INDEX,
additionalBehaviors: {},
errorResponses: [
{
httpStatus: 403,
responseHttpStatus: 200,
responsePagePath: `/${INDEX}`,
ttl: Duration.seconds(0),
},
{
httpStatus: 404,
responseHttpStatus: 200,
responsePagePath: `/${INDEX}`,
ttl: Duration.seconds(0),
},
],
});
// Point Route53 A and AAAA records to the CloudFront distribution
const aRecord = new ARecord(this, `${name}-cf-a-record`, {
zone,
target: RecordTarget.fromAlias(new CloudFrontTarget(cdn)),
recordName: `${domain}.`,
});
const aaaaRecord = new AaaaRecord(this, `${name}-cf-aaaa-record`, {
zone,
target: RecordTarget.fromAlias(new CloudFrontTarget(cdn)),
recordName: `${domain}.`,
});
Finally, what good would a distribution be without an associated domain? The ARecord
and AaaaRecord
point the domain hosted in a ROute53 public zone to our newly created distribution. During the initial deployment this part might hang for a bit as CDK verifies presence of these records.
Do I need to rewrite my URLs?
You’ve probably noticed the little helper method patchRootObject
we’re using to conditionally alter our requests. By default, request URLs ending in a slash (e.g. /about/
) will attempt to list a directory in S3. Likewise, requests without a file extension (e.g. /contact
) will point to a likely non-existent object. To avoid these issues, we can use a CloudFront function to append /index.html
in both cases, fixing the problem for generators like Hugo or Nextra.site.
This might not be required if your static site generator already ensures URLs always include the .html
suffix, which usually does not seem to be the case.
// Provision a CloudFront function to transparently
// append the /index.html suffix to page routes.
private patchRootObject(name: string): CFFunction {
const functionName = `${name}-patch-root-object`;
return new CFFunction(this, functionName, {
functionName,
comment: "Patch root object to add index.html",
code: FunctionCode.fromInline(`
function handler(event) {
const request = event.request;
const uri = request.uri;
if (uri.endsWith("/")) {
request.uri += "index.html";
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
`),
runtime: FunctionRuntime.JS_2_0,
});
}
Bonus: Cache invalidation during deployment
In case you need to invalidate your CloudFront distribution cache after new deployment goes through, there is way to do that via the CDK. We can create a small Node.js Lambda function that will create an invalidation on all matching paths (e.g. /*
). The function will be invoked by AwsCustomResource
, which is created and ran on every deployment.
private invalidateDeployment(
name: string,
cdn: Distribution,
deployment: BucketDeployment
): void {
const functionName = `${name}-invalidate-lambda`;
const invalidateLambda = new NodejsFunction(this, functionName, {
functionName,
runtime: Runtime.NODEJS_LATEST,
handler: "index.handler",
code: Code.fromInline(`
const { CloudFront } = require('@aws-sdk/client-cloudfront');
var cloudfront = new CloudFront();
exports.handler = async function(event, context) {
await cloudfront.createInvalidation({
DistributionId: '${cdn.distributionId}',
InvalidationBatch: {
CallerReference: new Date().getTime().toString(),
Paths: {
Quantity: 1,
Items: ['/*']
}
}
});
};
`),
});
cdn.grantCreateInvalidation(invalidateLambda);
const deploymentDate = new Date().toISOString();
const resourceName = `${name}-invalidate-resource-${deploymentDate}`;
const invalidateResource = new AwsCustomResource(this, resourceName, {
onCreate: {
service: "Lambda",
action: "invoke",
parameters: {
FunctionName: invalidateLambda.functionName,
},
physicalResourceId: PhysicalResourceId.of(resourceName),
},
onUpdate: {
service: "Lambda",
action: "invoke",
parameters: {
FunctionName: invalidateLambda.functionName,
},
physicalResourceId: PhysicalResourceId.of(resourceName),
},
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: AwsCustomResourcePolicy.ANY_RESOURCE,
}),
});
invalidateResource.node.addDependency(invalidateLambda);
invalidateResource.node.addDependency(deployment);
}
And that would be it! The construct code is terse yet expressive, Feel free to check the full construct on GitHub. While Cloudfront is an optimal choice if you’re already on AWS and prefer a consolidated invoice, there is a number of alternatives that obviate the need for a construct like the above.
Alternatives: Cloudflare Pages, Netlify, Vercel
CloudFront and S3 are great if you’re already invested in keeping your resources on AWS. I’ve personally had good experience with Netlify for Hugo sites, which streamlines the above process for Hugo deployments. Their GitHub integration allows for a super fast deployments on commit, which combined with a generous free tier makes it worth looking into.
Start the conversation