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 the 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
This offers a clearer separation of concerns and 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.
Defining construct props
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;
}
Creating the CloudFront distribution
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 is private by default. After the deployment, the files will be accessible only via the CloudFront distribution, and every deployment will trigger an invalidation of the cache to ensure latest files will be served.
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. enabling CORS policy defined on the bucket level.
- Response Headers Policy: Governs the headers that are included in responses from CF. Again, useful for configuring CORS.
- Function Associations: CloudFront Functions that may be called as part of request’s 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 point to them whenever a 403 or 404 error occurs.
import {
type Distribution,
FunctionEventType,
OriginRequestPolicy,
ResponseHeadersPolicy,
ViewerProtocolPolicy,
} from "aws-cdk-lib/aws-cloudfront";
import { Bucket, BucketEncryption } from "aws-cdk-lib/aws-s3";
import { BucketDeployment, Source } from "aws-cdk-lib/aws-s3-deployment";
import { Construct } from "constructs";
import { S3BucketOrigin } from "aws-cdk-lib/aws-cloudfront-origins";
export class StaticWebsite extends Construct {
public constructor(scope: Construct, id: string, props: StaticWebsiteProps) {
super(scope, id);
const INDEX = "index.html";
const { name, source, zone, certificate, domain, patchRootObject } = 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,
});
// 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),
},
],
});
// Deploy our compiled static assets to the bucket.
// Specifying the distribution will ensure an invalidation is triggered.
const _deployment = new BucketDeployment(this, `${name}-static-deployment`, {
destinationBucket: bucket.bucket,
sources: [Source.asset(source)],
distribution: cdn.distribution,
});
// 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 Route 53 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.
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.
import {
Function as CFFunction,
FunctionCode,
FunctionRuntime,
} from "aws-cdk-lib/aws-cloudfront";
// 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,
});
}
The construct code is concise yet expressive. Full code will soon be available on our GitHub.
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.