Skip to content
The Cloud Guy
ATC-008 AWS Guide

A static website on AWS for about fifty cents a month

S3, CloudFront, Route 53 and ACM, defined in one small CDK stack. The setup, the gotchas, what it really costs, and the cheapest way to do the same on Azure.

Published
Reading time
5 min
On this page
  1. What you’re building
  2. The whole stack
  3. The gotchas
  4. What it costs
  5. The same on Azure
  6. A checklist

I run several small static websites, this one included, and they all use the same AWS setup. Each costs roughly the price of its DNS zone, about fifty cents a month, plus the yearly domain renewal.

This guide is that setup: one CDK stack that you can copy, the handful of things that trip people up, and the real numbers.

What you’re building

Visitor one CDK stack, us-east-1 Route 53 alias A + AAAA, apex and www CloudFront HTTPS, caching, security headers CloudFront Function www → apex, /path/ → index.html S3 bucket, private read only through Origin Access Control ACM certificate
Everything a static site needs, and nothing that runs while nobody is visiting.
  • S3 holds the files, in a private bucket. Nobody reads from it directly.
  • CloudFront serves the site over HTTPS from edge locations near your visitors, caches everything, and adds security headers. It reads from the bucket through Origin Access Control.
  • A CloudFront Function handles two small jobs at the edge: it sends www visitors to the bare domain, and it turns /about/ into /about/index.html.
  • ACM provides the TLS certificate for free.
  • Route 53 points the domain at CloudFront with alias records.

Nothing here runs while nobody is visiting, which is why it costs so little.

The whole stack

This is a complete, working CDK app in TypeScript. Put your built site in a site/ folder next to it, change DOMAIN, and deploy.

import * as path from 'node:path';
import { App, Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as acm from 'aws-cdk-lib/aws-certificatemanager';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as origins from 'aws-cdk-lib/aws-cloudfront-origins';
import * as route53 from 'aws-cdk-lib/aws-route53';
import * as targets from 'aws-cdk-lib/aws-route53-targets';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';

const DOMAIN = 'example.com';

class StaticSiteStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: DOMAIN });

    const certificate = new acm.Certificate(this, 'Cert', {
      domainName: DOMAIN,
      subjectAlternativeNames: [`www.${DOMAIN}`],
      validation: acm.CertificateValidation.fromDns(zone),
    });

    const bucket = new s3.Bucket(this, 'Site', {
      blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
      enforceSSL: true,
      removalPolicy: RemovalPolicy.RETAIN,
    });

    // www -> apex, and directory URLs -> index.html
    const rewrite = new cloudfront.Function(this, 'Rewrite', {
      runtime: cloudfront.FunctionRuntime.JS_2_0,
      code: cloudfront.FunctionCode.fromInline(`
function handler(event) {
  var req = event.request;
  var host = req.headers.host.value;
  if (host.startsWith('www.')) {
    return { statusCode: 301, statusDescription: 'Moved Permanently',
      headers: { location: { value: 'https://' + host.slice(4) + req.uri } } };
  }
  if (req.uri.endsWith('/')) req.uri += 'index.html';
  else if (!req.uri.includes('.')) req.uri += '/index.html';
  return req;
}`),
    });

    const distribution = new cloudfront.Distribution(this, 'Cdn', {
      domainNames: [DOMAIN, `www.${DOMAIN}`],
      certificate,
      defaultRootObject: 'index.html',
      priceClass: cloudfront.PriceClass.PRICE_CLASS_100,
      defaultBehavior: {
        origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS,
        functionAssociations: [
          { function: rewrite, eventType: cloudfront.FunctionEventType.VIEWER_REQUEST },
        ],
      },
      errorResponses: [403, 404].map((httpStatus) => ({
        httpStatus,
        responseHttpStatus: 404,
        responsePagePath: '/404.html',
        ttl: Duration.minutes(5),
      })),
    });

    for (const name of [DOMAIN, `www.${DOMAIN}`]) {
      const target = route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution));
      new route53.ARecord(this, `A-${name}`, { zone, recordName: name, target });
      new route53.AaaaRecord(this, `AAAA-${name}`, { zone, recordName: name, target });
    }

    new s3deploy.BucketDeployment(this, 'Deploy', {
      sources: [s3deploy.Source.asset(path.join(__dirname, 'site'))],
      destinationBucket: bucket,
      distribution,
      distributionPaths: ['/*'],
    });
  }
}

const app = new App();
new StaticSiteStack(app, 'StaticSite', {
  env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: 'us-east-1' },
});

Then, once per account:

npx cdk bootstrap
npx cdk deploy

Every later cdk deploy uploads the site, deletes files you removed, and clears the CloudFront cache.

The gotchas

Deploy to us-east-1. CloudFront only accepts ACM certificates from the US East (N. Virginia) region. The simplest fix is to put the whole stack there, which is why the example pins the region. Your visitors are served from the edge anyway, so the bucket’s region barely matters.

Don’t use the S3 “website” endpoint. S3’s static website hosting needs a public bucket and can’t sit behind Origin Access Control. Keep the bucket private and use its normal endpoint through OAC. AWS calls the older Origin Access Identity method legacy, so skip that too.

You have to handle directory URLs yourself. CloudFront’s defaultRootObject only works for the root of the site. Without the small function, /about/ returns an error instead of /about/index.html. The function also redirects www to the bare domain, so you have one canonical address.

A private bucket returns 403, not 404, for missing files. That’s because CloudFront isn’t allowed to list the bucket. The errorResponses block turns both into your own 404.html with a proper 404 status.

Check for existing DNS records first. If the domain already has an A record for the apex or www, from an old host for example, the deploy fails because the record already exists. Delete the old records first, accepting that the old site goes dark until the deploy finishes.

fromLookup needs credentials at synth time. CDK looks up your hosted zone and caches the result in cdk.context.json. Commit that file, or pass the zone ID directly if you build in CI.

Cache invalidation is nearly free. Each deploy invalidates /*, which counts as one path, and the first 1,000 paths each month are free. If your build fingerprints its asset file names, you can cache those for a year and invalidate only the HTML.

What it costs

Item Monthly cost
Route 53 hosted zone $0.50
DNS queries for alias records pointing at CloudFront free
ACM certificate free
S3 storage for a small site a few cents at most
CloudFront data transfer and requests free up to 1 TB and 10 million requests a month
CloudFront Function free up to 2 million invocations a month
Total about $0.50, plus about $16 a year for a .com domain

The CloudFront figures are its always-free allowance on pay-as-you-go pricing, which is far beyond what a small site uses.

There’s now an even cheaper option. Since late 2025, CloudFront also offers flat-rate plans, including a Free plan at $0 a month. It bundles the CDN, DDoS protection, AWS WAF, Route 53 DNS for the domain, a certificate and 5 GB of S3 storage credits. It allows 1 million requests and 100 GB of transfer a month, with no overage charges. You pick a plan for a distribution in the CloudFront console after deploying. The fine print:

  • One domain per plan. Each plan covers one distribution and one apex domain, and an account can have up to three Free plans.
  • A WAF web ACL is required. It has to be attached to the distribution.
  • Legacy access isn’t supported. The plans don’t work with the old Origin Access Identity, which is another reason to use OAC.
  • Not for Free Tier accounts. AWS says accounts using the AWS Free Tier aren’t eligible.

For a hobby site, the always-free pay-as-you-go allowance is already more than enough. But the Free plan adds WAF and predictable billing, and if you attach the domain’s Route 53 hosted zone to the plan, the zone’s monthly fee is covered too, which brings the whole site to $0 a month plus the domain.

The same on Azure

On Azure, the cheapest equivalent is a different product, not the same pieces.

  • Azure Static Web Apps, Free plan. It’s free, with two custom domains per app and automatic, auto-renewing TLS certificates. The limits are 250 MB per environment, no SLA, and 100 GB of bandwidth a month shared by every Free app in the subscription, with no option to pay for more. The Standard plan, at $9 a month per app, adds more domains and an SLA. Static Web Apps also builds from GitHub or Azure DevOps for you, which is more than this AWS stack does.
  • Azure DNS costs the same as Route 53: $0.50 a month per zone.
  • Avoid Storage plus Front Door for a small site. Azure Storage’s static website feature can’t serve HTTPS on a custom domain by itself. For that you need Azure Front Door, whose Standard tier starts at a $35 monthly base fee. The classic Azure CDN from Microsoft no longer accepts new profiles and retires in September 2027.

So the honest comparison is this. On AWS: S3 plus CloudFront, defined in CDK, for about fifty cents a month, with full control. On Azure: Static Web Apps Free, for about fifty cents a month in DNS, with less to build yourself and tighter limits on site size.

A checklist

  • Private bucket, Origin Access Control, no public website endpoint
  • Certificate and stack in us-east-1
  • A function for www and directory URLs
  • 403 and 404 mapped to your own 404 page
  • Security headers on every response
  • Old DNS records cleared before the first deploy
  • A budget alert at a few dollars, just in case

Prices are US list prices as of October 2026. Sources: CloudFront pay-as-you-go pricing, CloudFront flat-rate plans, Route 53 pricing, ACM pricing, Static Web Apps quotas, Static Web Apps plans, Azure Front Door pricing, Custom domains for Azure Storage, Azure CDN classic retirement.

  • aws
  • s3
  • cloudfront
  • route53
  • cdk
  • static-site
  • azure
  • cost

Related

Have a project in mind?

Let's see if I can help.

Send a message