devto 2026-08-14 원문 보기 ↗
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.
npm run build
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.
index.html
index.html {
"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.
# 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.
Putting CloudFront in front isn't just adding a CDN. It's a structural shift in the trust model. Three things change together:
"*" to a scoped AWS service principal.Creating the distribution through the guided console flow looked like this:
bucket-name.s3.region.amazonaws.com) rather than the website endpoint*.cloudfront.net domain by defaultWhy 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.
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.
After the distribution deployed, hitting the CloudFront URL returned:
<Error>
<Code>AccessDenied</Code>
<Message>Access Denied</Message>
</Error>
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.
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.
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:
Principal: "*" statement, keeping only the CloudFront-scoped one.