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
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
- 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
wwwvisitors 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
wwwand directory URLs 403and404mapped 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