Skip to content
Stratus
All writing

7 min read

Hosting a Static Site on S3 + CloudFront with the AWS CDK

A complete CDK stack for a static site with HTTPS, a custom domain, and clean URLs — plus the three things that break on first deploy.


Every static-site-on-AWS tutorial gives you an S3 bucket and stops. Then you deploy, hit your domain, and get an SSL error — or a 403 on every URL that isn't the homepage. This post is the whole stack, including the parts that only show up after you deploy.

What we're building

  • An S3 bucket holding the built site, not public
  • CloudFront in front of it, serving over HTTPS from edge locations
  • An ACM certificate for a custom domain
  • Route 53 records pointing the domain at CloudFront
  • Clean URLs, so /blog/my-post/ resolves to my-post/index.html

Prerequisites

You need the CDK CLI and credentials configured:

npm install -g aws-cdk
cdk bootstrap aws://ACCOUNT_ID/us-east-1

Bootstrap once per account-region pair. If you skip it, your first cdk deploy fails with a message about a missing staging bucket.

The certificate region trap

Read this before writing any code, because it will cost you a deploy cycle otherwise.

CloudFront only accepts ACM certificates issued in us-east-1. Not your bucket's region. Not your Lambda's region. us-east-1, always, even if everything else you own lives in us-west-2.

There are two ways to handle this. The simpler one is to put the entire stack in us-east-1 — for a static site there's no real downside, since CloudFront serves from edge locations regardless of where the origin bucket sits. The other is a cross-region certificate stack, which is more moving parts than a blog needs.

We'll pin the whole stack to us-east-1.

The stack

import * as cdk from 'aws-cdk-lib';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import { Construct } from 'constructs';

interface BlogStackProps extends cdk.StackProps {
  domainName: string;
  siteSubDomain?: string;
}

export class BlogStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props: BlogStackProps) {
    super(scope, id, props);

    const siteDomain = props.siteSubDomain
      ? `${props.siteSubDomain}.${props.domainName}`
      : props.domainName;

    // Look up the hosted zone you already own.
    const zone = route53.HostedZone.fromLookup(this, 'Zone', {
      domainName: props.domainName,
    });

    // Private bucket. CloudFront reads from it; the public internet does not.
    const siteBucket = new s3.Bucket(this, 'SiteBucket', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      encryption: s3.BucketEncryption.S3_MANAGED,
      removalPolicy: cdk.RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // DNS-validated cert. Must be us-east-1 for CloudFront.
    const certificate = new acm.Certificate(this, 'SiteCertificate', {
      domainName: siteDomain,
      validation: acm.CertificateValidation.fromDns(zone),
    });

    // Rewrites /blog/post/ -> /blog/post/index.html at the edge.
    const rewriteFunction = new cloudfront.Function(this, 'RewriteFunction', {
      code: cloudfront.FunctionCode.fromInline(`
        function handler(event) {
          var request = event.request;
          var uri = request.uri;
          if (uri.endsWith('/')) {
            request.uri += 'index.html';
          } else if (!uri.includes('.')) {
            request.uri += '/index.html';
          }
          return request;
        }
      `),
    });

    const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
      defaultRootObject: 'index.html',
      domainNames: [siteDomain],
      certificate,
      defaultBehavior: {
        // S3BucketOrigin.withOriginAccessControl grants CloudFront read access
        // via OAC and writes the bucket policy for you.
        origin: origins.S3BucketOrigin.withOriginAccessControl(siteBucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        functionAssociations: [
          {
            function: rewriteFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
        ],
      },
      errorResponses: [
        {
          httpStatus: 404,
          responseHttpStatus: 404,
          responsePagePath: '/404.html',
        },
      ],
    });

    new route53.ARecord(this, 'SiteAliasRecord', {
      zone,
      recordName: siteDomain,
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(distribution)
      ),
    });

    // Upload the built site and invalidate the cache on every deploy.
    new s3deploy.BucketDeployment(this, 'DeploySite', {
      sources: [s3deploy.Source.asset('./dist')],
      destinationBucket: siteBucket,
      distribution,
      distributionPaths: ['/*'],
    });

    new cdk.CfnOutput(this, 'SiteURL', { value: `https://${siteDomain}` });
  }
}

And the app entry point:

#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { BlogStack } from '../lib/blog-stack';

const app = new cdk.App();

new BlogStack(app, 'BlogStack', {
  domainName: 'yourdomain.com',
  env: {
    account: process.env.CDK_DEFAULT_ACCOUNT,
    region: 'us-east-1',
  },
});

Why env is not optional here

HostedZone.fromLookup is a context lookup — at synth time the CDK calls the Route 53 API to find your zone, and it can't do that without knowing which account to call. If you leave env off, synth fails with a complaint about an environment-agnostic stack.

The lookup result gets cached in cdk.context.json. Commit that file. If you don't, CI does a fresh lookup on every build, and a failed lookup silently produces a dummy value that deploys a broken record set.

Origin Access Control, not the old public bucket

Older tutorials tell you to enable static website hosting on the bucket and make it public. Don't. That leaves your origin reachable directly, bypassing CloudFront entirely — so your HTTPS redirect, your edge function, and your cache all become optional from an attacker's point of view.

S3BucketOrigin.withOriginAccessControl handles this: CloudFront signs its requests to S3, and the bucket policy allows only that distribution.

The clean-URL problem

This is the 403 people hit. S3's REST API — which is what CloudFront talks to under OAC — has no concept of directory indexes. Request /blog/my-post/ and it looks for an object literally named blog/my-post/, finds nothing, and returns an error.

defaultRootObject: 'index.html' fixes only the root path. Every nested route still fails. The CloudFront Function above patches the URI at the edge before it reaches S3, which costs a fraction of what a Lambda@Edge function would for the same job.

Deploying

npm run build     # produces ./dist
npx cdk deploy

First deploy takes a while — most of it is the ACM validation waiting on DNS propagation, and CloudFront distributing config to edge locations. Fifteen to twenty minutes is normal. Subsequent deploys are much faster.

What this costs

For a low-traffic blog, realistically a few dollars a month or less: Route 53 charges a fixed fee per hosted zone, ACM certificates for use with CloudFront are free, and S3 storage for a text site is pennies. CloudFront has a free tier that covers a meaningful amount of monthly transfer before you pay anything.

Check current pricing before relying on any of these numbers — AWS changes them, and free tier terms have shifted for new accounts.

Next

The obvious follow-up is wiring this to GitHub Actions so a push to main builds and deploys, using OIDC instead of long-lived access keys. That's the next post.