Hosting a Static Site on AWS with S3 and CloudFront

TQ
Tran Quang
August 6, 2026 · 8 min read
#aws#s3#cloudfront#cdk

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:

text
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.

typescript
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:

json
{
  "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:

typescript
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:

bash
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_HEADERS as 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:PutObject and cloudfront:CreateInvalidation to 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.

Found this useful?

Share it with someone who might need it.

Related articles All articles