Hosting a Static Site on AWS with S3 and CloudFront
S3 plus CloudFront is the standard way to host static sites on AWS: marketing pages, documentation, and single-page apps built with React, Vue or Angular. S3 stores the files durably, CloudFront serves them from edge locations close to users with HTTPS and caching, and you never manage a web server.
The setup is easy to get subtly wrong, though. Older tutorials make the bucket public or use the legacy Origin Access Identity. This post builds the current recommended setup with the AWS CDK in TypeScript: a private bucket with Block Public Access on, CloudFront Origin Access Control (OAC), a custom domain, and a deployment flow that handles caching correctly.
Architecture overview
The request path is simple:
Browser -> Route 53 (alias) -> CloudFront distribution -> S3 bucket (private, via OAC)
The important property is that the bucket is not reachable directly. Only your CloudFront distribution can read objects, enforced by a bucket policy that trusts the CloudFront service principal and is scoped to your distribution's ARN. Users can only reach content through CloudFront, which means HTTPS, security headers and caching rules always apply.
Why not the S3 website endpoint
S3 has a built-in static website hosting feature, and it is tempting because it handles index documents and error pages. The trade-offs are significant:
| Aspect | S3 website endpoint as origin | S3 REST endpoint with OAC |
|---|---|---|
| Bucket access | Must allow public reads | Private, Block Public Access on |
| HTTPS between CloudFront and S3 | HTTP only | HTTPS |
| Origin type in CloudFront | Custom origin | S3 origin with OAC |
| Index document in subfolders | Built in | Needs a CloudFront Function |
| SSE-KMS encrypted objects | Not supported for public website reads | Supported with a KMS key policy |
Keeping the bucket private is worth the small amount of extra work for subfolder index documents, which a CloudFront Function handles cleanly.
OAC versus the legacy OAI
Origin Access Identity (OAI) was the original mechanism for letting CloudFront read a private bucket. AWS now recommends Origin Access Control instead. OAC signs requests to S3 with SigV4, supports SSE-KMS, works in all regions, and supports write methods if you ever need them. For new distributions there is no reason to use OAI, and in the CDK the S3BucketOrigin.withOriginAccessControl helper creates the OAC and the matching bucket policy for you.
The CDK stack
The ACM certificate for a CloudFront distribution must be issued in us-east-1, regardless of where the bucket lives. The simplest approach is to deploy this stack to us-east-1, or create the certificate in a separate us-east-1 stack and pass it across with crossRegionReferences enabled.
import { Stack, StackProps, Duration, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
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';
export class StaticSiteStack extends Stack {
constructor(scope: Construct, id: string, props?: StackProps) {
super(scope, id, props);
const domainName = 'www.example.com';
const zone = route53.HostedZone.fromLookup(this, 'Zone', { domainName: 'example.com' });
const bucket = new s3.Bucket(this, 'SiteBucket', {
blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL,
encryption: s3.BucketEncryption.S3_MANAGED,
enforceSSL: true,
versioned: true,
removalPolicy: RemovalPolicy.RETAIN,
});
const certificate = new acm.Certificate(this, 'SiteCert', {
domainName,
validation: acm.CertificateValidation.fromDns(zone),
});
const rewriteFn = new cloudfront.Function(this, 'IndexRewrite', {
runtime: cloudfront.FunctionRuntime.JS_2_0,
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.split('/').pop().includes('.')) {
request.uri += '/index.html';
}
return request;
}`),
});
const distribution = new cloudfront.Distribution(this, 'SiteDistribution', {
defaultRootObject: 'index.html',
domainNames: [domainName],
certificate,
minimumProtocolVersion: cloudfront.SecurityPolicyProtocol.TLS_V1_2_2021,
httpVersion: cloudfront.HttpVersion.HTTP2_AND_3,
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
responseHeadersPolicy: cloudfront.ResponseHeadersPolicy.SECURITY_HEADERS,
compress: true,
functionAssociations: [{
function: rewriteFn,
eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
}],
},
});
new route53.ARecord(this, 'AliasA', {
zone,
recordName: domainName,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
new route53.AaaaRecord(this, 'AliasAAAA', {
zone,
recordName: domainName,
target: route53.RecordTarget.fromAlias(new targets.CloudFrontTarget(distribution)),
});
}
}
The CloudFront Function above is for multi-page static sites (Hugo, Astro, Docusaurus and similar) where /about/ should serve /about/index.html. defaultRootObject only applies to the root URL, not to subfolders. For a pure single-page app, skip the function and use the error response approach in the next section.
The bucket policy OAC needs
The CDK writes the bucket policy for you, but it is worth knowing what it looks like, especially if you manage infrastructure with CloudFormation or Terraform:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontServicePrincipalReadOnly",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::my-site-bucket/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::123456789012:distribution/E1ABCDEFGHIJKL"
}
}
}
]
}
The AWS:SourceArn condition is what prevents any other CloudFront distribution, including one in another account, from reading your bucket. Never drop it.
If the bucket uses SSE-KMS with a customer managed key, the KMS key policy must also allow kms:Decrypt for the cloudfront.amazonaws.com principal with the same source ARN condition. SSE-S3 needs no extra configuration.
Single-page app routing
A client-side router handles paths like /dashboard/settings, but no such object exists in S3. Because the policy only grants s3:GetObject and not s3:ListBucket, S3 returns 403 Forbidden for missing keys rather than 404. For an SPA, map both to index.html with a 200 status:
const distribution = new cloudfront.Distribution(this, 'SpaDistribution', {
defaultRootObject: 'index.html',
defaultBehavior: {
origin: origins.S3BucketOrigin.withOriginAccessControl(bucket),
viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS,
},
errorResponses: [
{ httpStatus: 403, responseHttpStatus: 200, responsePagePath: '/index.html', ttl: Duration.seconds(0) },
{ httpStatus: 404, responseHttpStatus: 200, responsePagePath: '/index.html', ttl: Duration.seconds(0) },
],
});
Be aware of the trade-off: error responses apply to the whole distribution. If the same distribution also routes /api/* to a backend, a real 403 or 404 from the API will be replaced with your HTML. In that case, prefer a CloudFront Function on the default behavior that rewrites extensionless paths to /index.html, and leave API behaviors untouched.
Caching strategy
Good caching comes from the headers you set on objects, not only from CloudFront settings. Modern build tools emit fingerprinted asset names such as main.4f9a2c.js, which can be cached for a long time because a change produces a new file name. index.html is the entry point that references them, so it must always be revalidated.
| File type | Cache-Control | Reason |
|---|---|---|
| Hashed JS, CSS, fonts, images | public, max-age=31536000, immutable |
Name changes when content changes |
index.html and other HTML |
no-cache |
Must pick up new asset references |
robots.txt, sitemap.xml |
Short max-age |
Updated occasionally |
CachePolicy.CACHING_OPTIMIZED respects origin Cache-Control headers within its TTL bounds, so these headers drive both browser and edge caching.
Deploying content
For a pipeline that is independent of infrastructure changes, the AWS CLI is straightforward. Upload assets first, then HTML, then invalidate only what needs it:
BUCKET=my-site-bucket
DIST_ID=E1ABCDEFGHIJKL
# 1. Fingerprinted assets: long-lived cache
aws s3 sync ./dist "s3://$BUCKET" \
--exclude "*.html" \
--cache-control "public,max-age=31536000,immutable"
# 2. HTML: always revalidate
aws s3 sync ./dist "s3://$BUCKET" \
--exclude "*" --include "*.html" \
--cache-control "no-cache"
# 3. Remove files that no longer exist in the build
aws s3 sync ./dist "s3://$BUCKET" --delete --size-only
# 4. Invalidate HTML only
aws cloudfront create-invalidation \
--distribution-id "$DIST_ID" \
--paths "/index.html" "/*.html"
Uploading assets before HTML matters: a user who loads the new index.html must be able to fetch the new bundles immediately. Deleting old files last (or not at all for a few releases) avoids breaking users who still have the previous index.html open.
If you prefer to keep everything in CDK, BucketDeployment from aws-cdk-lib/aws-s3-deployment can upload a folder and invalidate the distribution on each deploy. It is convenient for small sites; for larger ones, a CI job running the CLI gives you finer control over headers and invalidations.
Security and operational tips
- Keep Block Public Access enabled at the account level, not just the bucket.
- Use
ResponseHeadersPolicy.SECURITY_HEADERSas a baseline, or create a custom policy with a Content Security Policy tailored to your app. - Enable bucket versioning so a bad deploy can be rolled back by restoring previous object versions.
- Turn on CloudFront standard logging or real-time logs if you need traffic analysis, and consider AWS WAF on the distribution for public sites that attract bots.
- Restrict who can run
s3:PutObjectandcloudfront:CreateInvalidationto your deployment role, ideally assumed via OIDC from CI.
Conclusion
A private S3 bucket behind CloudFront with Origin Access Control is the right default for static hosting on AWS. It keeps content off the public internet except through your distribution, gives you HTTPS and edge caching, and costs very little to run. Most of the real-world problems come from routing and caching rather than from the infrastructure itself, so decide early whether you need subfolder index rewriting or SPA fallbacks, set Cache-Control per file type, and deploy assets before HTML. With that in place, deployments become a routine sync and a small invalidation.