3 min readupdated
A static site on S3 and CloudFront, without the footguns
The decisions behind this site's original AWS setup — a private bucket, directory URLs at the edge, split cache headers, an atomic visitor counter — and why IAM took the most time.
Moga Taufiq
Full-Stack & AI Systems Engineer · Moviq
On this page
This site is a Next.js static export. From June 2026 it was stored in S3 and served by CloudFront on a custom domain, with a small serverless visitor counter behind it — my take on the Cloud Resume Challenge: AWS primitives only, with security and cost treated as requirements rather than afterthoughts. It now runs on Vercel, but everything below still applies to any static site on S3 and CloudFront.
These are the decisions that mattered, roughly in the order you will run into them.
1. Keep the bucket private
S3's public website endpoint is the quickest way to host files, and the wrong one here: it exposes the bucket directly and lets traffic skip the CDN. Instead, the bucket stays private and only CloudFront may read it, through Origin Access Control (OAC). The bucket policy grants read access to one distribution and nothing else:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontRead",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::YOUR_BUCKET/*",
"Condition": {
"StringEquals": {
"AWS:SourceArn": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
}
}
]
}
2. Directory URLs need a rewrite at the edge
The catch with a private bucket: the S3 REST origin has no index documents. A request for /projects/neopark-smart-parking/ does not magically become index.html.
With trailingSlash: true, Next.js exports every page as <path>/index.html, so a tiny CloudFront Function on the viewer request completes the mapping — and redirects slash-less URLs, so each page has exactly one canonical address:
function handler(event) {
var request = event.request
var uri = request.uri
// /projects/neopark/ → /projects/neopark/index.html
if (uri.endsWith('/')) {
request.uri = uri + 'index.html'
return request
}
// /projects/neopark → 301 → /projects/neopark/
if (!uri.split('/').pop().includes('.')) {
return {
statusCode: 301,
statusDescription: 'Moved Permanently',
headers: { location: { value: uri + '/' } },
}
}
return request
}
3. The certificate lives in us-east-1
CloudFront only reads TLS certificates from ACM in us-east-1, whatever region your bucket is in. Request the certificate there, validate it through DNS, attach it to the distribution, and point Route 53 alias records for both the apex and www at CloudFront.
4. Split cache headers by how often files change
Fingerprinted assets never change; HTML has to reflect each deploy immediately. So the deploy syncs twice with different headers, then invalidates CloudFront:
# Fingerprinted assets: cache for a year
aws s3 sync out/ "s3://$BUCKET" --delete \
--cache-control "public, max-age=31536000, immutable" \
--exclude "*.html" --exclude "*.xml" --exclude "*.txt"
# Documents: always revalidate
aws s3 sync out/ "s3://$BUCKET" --delete \
--cache-control "public, max-age=0, must-revalidate" \
--exclude "*" --include "*.html" --include "*.xml" --include "*.txt"
aws cloudfront create-invalidation --distribution-id "$DISTRIBUTION_ID" --paths "/*"
One caveat: immutable is only safe for files whose name changes when their content does. Next's _next/static output qualifies; a hand-named cover.webp does not — when you replace an image, give it a new name. (This site's CMS content-hashes every upload for exactly this reason.)
5. Count visitors without a race condition
The obvious counter — read the count, add one, write it back — races when two visits land at once. A single DynamoDB UpdateItem with ADD does the increment atomically on the server:
import { DynamoDBClient, UpdateItemCommand } from '@aws-sdk/client-dynamodb'
const db = new DynamoDBClient({})
export async function handler() {
const result = await db.send(
new UpdateItemCommand({
TableName: process.env.TABLE_NAME,
Key: { id: { S: 'visitors' } },
UpdateExpression: 'ADD #count :one',
ExpressionAttributeNames: { '#count': 'count' },
ExpressionAttributeValues: { ':one': { N: '1' } },
ReturnValues: 'UPDATED_NEW',
}),
)
return {
statusCode: 200,
body: JSON.stringify({ count: Number(result.Attributes?.count?.N ?? 0) }),
}
}
On on-demand billing that is one hot item and no idle cost — plenty for a portfolio, behind API Gateway and a Node.js Lambda.
6. IAM is the real curriculum
Most of the friction in the whole project was IAM: OAC bucket policies, Lambda execution roles, and least privilege for the pipeline. Once those clicked, the other services felt like Lego.
For the deploy pipeline, give CI a dedicated identity that can touch this one bucket and this one distribution — nothing more:
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "arn:aws:s3:::YOUR_BUCKET" },
{
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject"],
"Resource": "arn:aws:s3:::YOUR_BUCKET/*"
},
{
"Effect": "Allow",
"Action": ["cloudfront:CreateInvalidation"],
"Resource": "arn:aws:cloudfront::ACCOUNT_ID:distribution/DISTRIBUTION_ID"
}
]
}
Better still, let GitHub Actions assume that role through OIDC instead of storing long-lived access keys as secrets: there is nothing to rotate and nothing to leak.
The result
From git push to live in under five minutes, for a monthly bill of a couple of dollars (domain aside). None of it is exotic — which is the point. The boring setup, done carefully, is the one that keeps working.
The project behind this
Dealing with something like this?
I help teams design and ship systems like the ones in these notes. Tell me what you’re working on.