Build your React app into static files, upload them to a private S3 bucket, and serve them through CloudFront with origin access control. Keep S3 Block Public Access enabled: users reach the CloudFront HTTPS endpoint rather than a public bucket website.
Project layout
This guide assumes a static React application that already builds successfully. It does not require a particular app generator. In a sibling CDK TypeScript app, use this layout:
project/ web/ # React project dist/ # generated production files infra/ # CDK TypeScript app lib/ bin/
Run npm ci and npm run build in web. If your framework emits build instead of dist, change the asset path below. Build output must exist before CDK synthesizes the deployment asset.
In infra, use aws-cdk-lib 2.272.0 and constructs 10.5.0. Instantiate ReactDeploymentStack from the CDK app entry point.
Define hosting and deployment
import { CfnOutput, Duration, Stack, StackProps } from "aws-cdk-lib"; 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 deployment from "aws-cdk-lib/aws-s3-deployment"; import { Construct } from "constructs"; import { resolve } from "node:path"; export class ReactDeploymentStack extends Stack { constructor(scope: Construct, id: string, props?: StackProps) { super(scope, id, props); const bucket = new s3.Bucket(this, "SiteFiles", { blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, enforceSSL: true, }); const distribution = new cloudfront.Distribution(this, "Site", { defaultRootObject: "index.html", defaultBehavior: { origin: origins.S3BucketOrigin.withOriginAccessControl(bucket), viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, }, errorResponses: [403, 404].map((httpStatus) => ({ httpStatus, responseHttpStatus: 200, responsePagePath: "/index.html", ttl: Duration.seconds(0), })), }); new deployment.BucketDeployment(this, "UploadSite", { sources: [deployment.Source.asset(resolve("../web/dist"))], destinationBucket: bucket, distribution, distributionPaths: ["/*"], }); new CfnOutput(this, "SiteUrl", { value: `https://${distribution.distributionDomainName}`, }); } }
Run CDK commands from infra so the relative asset path resolves correctly. The S3BucketOrigin reference documents origin access control. This uses an S3 REST origin, not its static website endpoint.
BucketDeployment manages the destination contents; by default it prunes files missing from the source. Use a dedicated deployment bucket, not one containing unrelated uploads.
Deploy and verify
npx tsc --noEmit npx aws-cdk synth npx aws-cdk diff npx aws-cdk deploy --outputs-file stack-outputs.json
Bootstrap the account and region first if required. Open SiteUrl, follow a client-side route, and refresh it directly. Confirm that the distribution serves the app and direct anonymous S3 reads remain denied. CloudFront provisioning and invalidation can take time; no AWS deployment was performed for this revision.
Routing and SEO limits
The broad error fallback supports a small client-rendered application, but also turns missing assets and invalid paths into HTTP 200 responses. For an indexable content site, use prerendered or server-rendered pages with real 404 responses, and design route rewrites narrowly. Do not use this fallback as an SEO strategy for a blog.
CloudFront is managed infrastructure; you do not use an Auto Scaling group to scale the distribution. For custom domains, add an ACM certificate in the required region and configure DNS, aliases, cache headers, and application security separately.
Cleanup
Review the exercise stack's destroy proposal. The S3 bucket is retained by default; inspect its contents before deliberately emptying or removing it. Check CloudFront and retained resources for ongoing charges. For cross-origin API or upload requests, see S3 CORS; for backend compute, see Lambda HTTP API with CDK.
Quick Recap
Does the S3 bucket need to be public?
No. Use a private S3 REST origin and CloudFront origin access control. S3 Block Public Access remains enabled.
Does this host a server-rendered React application?
No. This serves static build files. A framework that needs a server runtime requires a hosting architecture that supports that runtime.
Why do deep links need a fallback?
A client-side route such as /settings is not usually an S3 object. The demo maps origin 403 and 404 errors to index.html, but that also hides genuinely missing assets and is not suitable for every site.
Comments
Share your thoughts and questions below