Deploying a React App to AWS S3 + CloudFront
DEV Community

Deploying a React App to AWS S3 + CloudFront

What I built CountryRank is a React (Vite) app for exploring and comparing countries. I deployed it to AWS using a production-grade static hosting setup: a private S3 bucket serving the build output, sitting behind CloudFront for HTTPS, caching, and correct handling of client-side routing. Stack - React + Vite: the app itself, built to static assets with npm run build - AWS S3: private bucket storing the build output, no public access - AWS CloudFront: CDN in front of S3, HTTPS, custom error handling for SPA routing - AWS IAM (bucket policy): scoped access control via a service principal and condition - Origin Access Control (OAC): the mechanism that lets CloudFront read from the private bucket Architecture I built this project in two deliberate stages. I set up plain S3 static hosting first to understand the baseline, then added CloudFront and OAC after seeing the limitations of the S3-only setup. Step 1: S3 bucket setup - Create the bucket. The name has to be globally unique, lowercase and hyphens only. Dots break SSL matching later if CloudFront gets added. - Turn off Block Public Access for this initial public-hosting stage. AWS made me type a confirmation phrase here rather than just unchecking a box, a useful signal that this is a consequential decision and not a routine toggle. - Enable static website hosting on the bucket: - Index document: index.html - Error document: index.html - Index document: - Attach a bucket policy granting public read access: { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Principal": "", "Action": "s3:GetObject", "Resource": "arn:aws:s3:::dubem-country-rank-app/" } ] } Two details worth internalizing here. Resource needs /* because s3:GetObject acts on individual objects, not the bucket itself; the bucket ARN without /* refers to the container (its configuration, its listing) rather than what's inside it. And S3 validates the bucket name in the policy against the actual bucket it's attached to, so a typo gets rejected immediately on save. - Upload the build. This is where S3's flat structure matters: # Wrong: nests everything under a dist/ prefix, so index.html isn't found at root aws s3 sync dist s3://dubem-country-rank-app/ # Right: the trailing slash means "copy the contents of dist", not "dist as a unit" aws s3 sync dist/ s3://dubem-country-rank-app/ S3 has no real folders. Everything is a flat list of objects with string keys, and the console just visually groups keys that share a prefix. sync (as opposed to cp --recursive ) only uploads what's changed, which matters once I'm redeploying repeatedly. At this point the site was live on the S3 website endpoint. No server was running anywhere, just object storage answering HTTP requests. But it had real gaps: no HTTPS, no CDN, no custom domain and broken client-side routing. A path like /countries/nigeria isn't a real object in the bucket, so direct navigation or a refresh returns a 404 unless the error document papers over it. Even then, the response still carries a 404 or 403 status code, which is bad for SEO and monitoring. That's what CloudFront fixes. Step 2: CloudFront setup Putting CloudFront in front isn't just adding a CDN. It's a structural shift in the trust model. Three things change together: - The bucket goes back to fully private. - CloudFront gets a dedicated identity via OAC to read from it. - The bucket policy's principal changes from "" to a scoped AWS service principal. Creating the distribution through the guided console flow looked like this: - Distribution type: Single website or app - Origin: selected via Browse S3, which resolves to the bucket's REST/object endpoint ( bucket-name.s3.region.amazonaws.com ) rather than the website endpoint - No custom Route 53 domain for now; CloudFront gives a free .cloudfront.net domain by default - WAF's managed protections left on default (free tier, no cost) rather than customized Why the REST endpoint and not the website endpoint? OAC works by having CloudFront sign its requests to S3, and only the REST API understands signed requests. The website-hosting endpoint is public-only by design and can't validate a signature. Since the bucket is going private anyway, the website endpoint becomes irrelevant at this stage regardless. One consequence worth flagging: the REST endpoint has no concept of index or error documents. Everything configured in Step 1's hosting settings doesn't carry over. CloudFront needs its own equivalent settings, covered next. Step 3: Setting up OAC Enabling "Allow private S3 bucket access to CloudFront" during distribution creation is the actual OAC toggle. It does two things: it creates an Origin Access Control resource, and it auto-generates a bucket policy update scoped to that resource. The generated policy looked like this: { "Version": "2012-10-17", "Statement": [ { "Sid": "AllowCloudFrontServicePrincipal", "Effect": "Allow", "Principal": { "Service": "cloudfront.amazonaws.com" }, "Action": "s3:GetObject", "Resource": "arn:aws:s3:::dubem-country-rank-app/", "Condition": { "ArnLike": { "AWS:SourceArn": "arn:aws:cloudfront::942004241182:distribution/E2XUCTY83EAT31" } } } ] } The Condition block matters more than it looks. Service: cloudfront.amazonaws.com alone would let any CloudFront distribution in any AWS account read the bucket. Scoping it to a specific SourceArn restricts it to this one distribution. Without that, I would just be trading one public-access problem for a slightly narrower one. Important: enabling OAC updates the bucket policy, but it doesn't touch Block Public Access or remove prior policy statements automatically. That's what bit me next. Errors I hit and how I fixed them 1. AccessDenied on the CloudFront root URL After the distribution deployed, hitting the CloudFront URL returned: AccessDenied Access Denied I reasoned through it like this: when requesting / , what object key was CloudFront actually asking S3 for? Nothing in the setup so far told CloudFront that / should resolve to index.html . That's a distribution-level setting called Default Root Object, and it isn't inherited from anything configured back in Step 1. Fix: in the distribution, go to General, click Edit and set Default Root Object to index.html . 2. Client-side routes still broken after that Fixing the root didn't fix direct navigation to /countries/nigeria . Same underlying problem as the S3-only stage, still unresolved, because Default Root Object only handles the exact-root case. Fix: Custom Error Responses on the distribution: | HTTP error code | Response page path | HTTP response code | |---|---|---| | 403 | /index.html | 200 | | 404 | /index.html | 200 | Both codes are needed because a private bucket accessed via OAC tends to return 403 for a missing key rather than a clean 404. It doesn't distinguish "doesn't exist" from "not allowed to see it," it just denies. This is the fix plain S3 hosting couldn't offer: CloudFront can rewrite the status code itself, not just swap in different content, so a deep-linked SPA route now returns a legitimate 200 while React Router takes over client-side. 3. Old public bucket policy left stacked on top of the new one After everything was working, I went back and checked the bucket policy directly instead of assuming the wizard had cleaned up after itself. I found the original Principal: "" statement from Step 1 still sitting there, alongside the new CloudFront-scoped one. Block Public Access was also still off. That meant the bucket was reachable two ways: through CloudFront (secured, cached, HTTPS) and directly via the old public endpoint (none of that), quietly defeating the point of the whole migration. Fix: - Removed the old Principal: "*" statement, keeping only the CloudFront-scoped one. - Re-enabled Block Public Access. - Verified by testing both URLs: the CloudFront URL still worked, and the direct S3 endpoint failed. That's the actual proof the bucket is only reachable through the intended path. What I learned - S3 access control is layered: Block Public Access is the master override, the bucket policy is the grant, and the resource ARN scope decides bucket versus object. All three have to align, and missing any one breaks things in a specific, diagnosable way. - S3 "folders" are prefixes, not real directories. This affects upload commands directly, not just how the console displays things. - Website-hosting endpoints and REST endpoints are genuinely different features with different capabilities. OAC only works with the REST one. - CloudFront doesn't inherit S3's static-hosting settings. Default Root Object and Custom Error Responses have to be configured independently, and skipping them produces specific, traceable errors rather than vague failures. - Auto-generated policies from console wizards can leave old, conflicting statements behind. It's worth checking the actual policy JSON manually rather than assuming the tool fully cleaned up after itself. - Debugging AccessDenied is faster when reasoning through which layer is missing (policy, public access block, or routing config) rather than guessing and re-toggling settings at random. Top comments (0)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.