Why We Deploy Static Sites with AWS CDK and CloudFront

Million Lights Media

The millionlightsmedia.com site you're reading right now runs on a stack we've become confident in: Eleventy for static generation, S3 for storage, CloudFront for global delivery, and AWS CDK to provision and manage all of it as code.

The choice to use CDK instead of clicking through the AWS console (or reaching for Terraform) comes down to one thing: the same team that writes TypeScript for the front end can write TypeScript for the infrastructure. No new toolchain, no new language, no context switching between YAML and application code.

The Stack

A typical deployment for a static marketing site involves:

  • S3 bucket — origin storage for compiled HTML, CSS, JS, and image assets
  • CloudFront distribution — CDN with custom domain, ACM certificate, and cache behavior configuration
  • Route 53 — DNS records pointing the apex and www subdomain at the CloudFront distribution
  • CodePipeline + CodeBuild — CI/CD triggered on GitHub push, running npm ci && npx eleventy and syncing the output to S3
  • CloudFront Function — rewrites clean URLs (e.g. /about/about/index.html) so Eleventy's output works correctly without .html extensions in the URL bar

All of this lives in CDK stacks committed alongside the application code. When infrastructure changes are needed — adding a new response header, adjusting cache TTLs, adding a new subdomain — the change goes through a pull request and deploy like any other code change.

Lessons From Production

A few things that aren't obvious until you run this in production:

CloudFront caching is aggressive by default. If you deploy a new build and your users still see the old one, the issue is almost always cache invalidation. We run an S3 sync followed by a aws cloudfront create-invalidation --paths "/*" at the end of every build.

Custom error responses are non-negotiable. S3 returns a 403 Access Denied for missing keys — not a 404. Without a CloudFront error-response rule mapping both 403 and 404 to your custom 404/index.html, users get an ugly XML error page instead of a styled not-found page. This catches teams by surprise.

HSTS needs to be set at the CloudFront layer. You can't set response headers in S3 directly; they have to come from a CloudFront Response Headers Policy or a CloudFront Function. For a production site, Strict-Transport-Security: max-age=31536000; includeSubDomains should be in every response.

npm ci in CodeBuild, not npm install. ci respects the lockfile. install can silently upgrade transitive dependencies between deploys, which introduces unpredictable behavior in production builds that doesn't exist locally.

When Not to Use This Stack

This stack is optimal for content-heavy marketing sites, documentation, and portfolios — any site where the HTML can be pre-generated. For sites that require per-request server logic (dynamic personalization, authenticated dashboards, real-time data), a different architecture is appropriate. We use API Gateway + Lambda for the contact form endpoint on this site, which handles the one piece of server-side logic we actually need.


Interested in this architecture for your project? Get in touch or see how we work.