
Host a Fast, Free Portfolio Website with S3 and CloudFront
A step-by-step guide to hosting a static portfolio on Amazon S3 and serving it globally through CloudFront — using AWS's current security setup, not the outdated public-bucket tutorial still floating around online.
What you're building
A personal portfolio site — HTML, CSS, JS, maybe a resume PDF — served
fast to visitors anywhere in the world, over HTTPS, on your own domain
if you want one. Two AWS services do the work:
fast to visitors anywhere in the world, over HTTPS, on your own domain
if you want one. Two AWS services do the work:
- Amazon S3 stores your actual files. Think of it as a folder in
the cloud, not a web server. - Amazon CloudFront is a CDN (content delivery network) — it copies
your files to servers ("edge locations") around the world, so a
visitor in Singapore isn't waiting on a request to travel to
wherever your S3 bucket actually lives.
S3 alone can serve files, but every visitor everywhere would be
fetching directly from one region — slow for anyone far from it, and
harder to get free HTTPS on a custom domain. CloudFront in front of S3
fixes both.
fetching directly from one region — slow for anyone far from it, and
harder to get free HTTPS on a custom domain. CloudFront in front of S3
fixes both.
What you'll need
- An AWS account (the free tier covers this comfortably)
- Your finished site files (
index.html, CSS, JS, images — built by
hand, or output from something like a static site generator) - Optional: a domain name, if you want
yourname.devinstead of a
longcloudfront.netaddress
Step 1: Create a private S3 bucket
In the S3 console, create a bucket. Bucket names are globally unique,
so
so
my-portfolio is almost certainly taken — try something likeyourname-portfolio-2026.Leave "Block all public access" turned ON. This is the part that
differs from older tutorials — we're deliberately keeping the bucket
private. CloudFront will be the only thing allowed to read from it.
differs from older tutorials — we're deliberately keeping the bucket
private. CloudFront will be the only thing allowed to read from it.
Step 2: Upload your site files
Upload your files into the bucket, keeping the same folder structure
your site uses locally (
if you have one, and so on).
your site uses locally (
index.html at the root, an /assets/ folderif you have one, and so on).
Step 3 (optional): Request an HTTPS certificate
Only needed if you want a custom domain. In AWS Certificate Manager
(ACM), request a public certificate for your domain — with one
non-obvious rule: the certificate must be requested in the
us-east-1 (N. Virginia) region, regardless of where you or your
users actually are. CloudFront only accepts certificates from that
region. Validate it via DNS (ACM will give you a CNAME record to add).
(ACM), request a public certificate for your domain — with one
non-obvious rule: the certificate must be requested in the
us-east-1 (N. Virginia) region, regardless of where you or your
users actually are. CloudFront only accepts certificates from that
region. Validate it via DNS (ACM will give you a CNAME record to add).
Step 4: Create the CloudFront distribution
In the CloudFront console, create a distribution:
- Origin domain: select your S3 bucket. CloudFront will offer two
connection types — pick the REST API endpoint (the default option
when you select the bucket directly), not the separate "website
endpoint." - Origin access: choose Origin access control (OAC), then
create a new OAC. This is what lets CloudFront read your private
bucket while everyone else is locked out. - Viewer protocol policy: redirect HTTP to HTTPS.
- Alternate domain name (CNAME): your custom domain, if using one
— plus the ACM certificate from Step 3.
After creating the distribution, CloudFront shows you a bucket policy
statement. Copy it and paste it into your S3 bucket's permissions →
bucket policy. This is the step that actually grants CloudFront
access — skipping it is the most common reason a fresh setup returns
"Access Denied."
statement. Copy it and paste it into your S3 bucket's permissions →
bucket policy. This is the step that actually grants CloudFront
access — skipping it is the most common reason a fresh setup returns
"Access Denied."
Step 5: Set the default root object
Still in the distribution settings, set default root object to
returns an error instead of your homepage.
index.html. Without this, visiting your bare domain (no filename)returns an error instead of your homepage.
Step 6: Fix the "pretty URL" gap
Here's a gap most tutorials skip. S3's old "static website hosting"
feature used to auto-handle folder-style URLs (
that automatically — CloudFront will return an access error for any
URL that doesn't point to an exact file.
feature used to auto-handle folder-style URLs (
/projects/ → serves/projects/index.html). The private-bucket-plus-OAC setup doesn't dothat automatically — CloudFront will return an access error for any
URL that doesn't point to an exact file.
Fix it with a small CloudFront Function (Functions → Create
function, attach it to your distribution's default cache behavior as
a viewer request function):
function, attach it to your distribution's default cache behavior as
a viewer request function):
1
2
3
4
5
6
7
8
9
10
11
12
function handler(event) {
var request = event.request;
var uri = request.uri;
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}This rewrites
/projects/ and /projects into/projects/index.html before the request reaches S3.If your portfolio is a single-page app (React, Vue, etc.) rather than
plain HTML pages, you'll also want to set a custom error response
in the distribution: for HTTP 403 and 404, respond with
and an HTTP status of 200. That lets your app's own client-side router
handle the URL instead of AWS returning an error page.
plain HTML pages, you'll also want to set a custom error response
in the distribution: for HTTP 403 and 404, respond with
/index.htmland an HTTP status of 200. That lets your app's own client-side router
handle the URL instead of AWS returning an error page.
Step 7: Point your domain at CloudFront
If you're using a custom domain through Route 53, create an A
record (and AAAA, for IPv6) of type Alias, pointing at your
CloudFront distribution. Alias records are Route 53's way of pointing
a domain directly at an AWS resource without needing a fixed IP
address.
record (and AAAA, for IPv6) of type Alias, pointing at your
CloudFront distribution. Alias records are Route 53's way of pointing
a domain directly at an AWS resource without needing a fixed IP
address.
Step 8: Updating your site later
When you change your site, upload the new files to S3 — but
CloudFront caches content at the edge, so visitors might keep seeing
the old version for a while. To force it to grab the new files
immediately, create a cache invalidation for the changed paths (or
CloudFront caches content at the edge, so visitors might keep seeing
the old version for a while. To force it to grab the new files
immediately, create a cache invalidation for the changed paths (or
/* for everything) from the CloudFront console or CLI:1
2
3
aws cloudfront create-invalidation \
--distribution-id YOUR_DISTRIBUTION_ID \
--paths "/*"What this actually costs
For a personal portfolio, realistically close to nothing. < cite index="20-1">CloudFront's Always Free tier includes 1 TB of data transfer out and 10 million HTTP/HTTPS requests per month,</cite> and < cite index="25-2">that free allowance covers CloudFront Functions too, at 2 million invocations a month</cite> — the function from Step 6 included. On top of that, < cite index="23-3">data transferred from an S3 origin to CloudFront doesn't cost anything at all</cite> — the only realistic charge is a few cents a month for S3 storage of your actual files. A portfolio site would need to go seriously viral to ever see a bill.
Common mistakes to avoid
- Using the S3 "static website hosting" checkbox with a public
bucket. It's the tutorial you'll find most often, and it's the
outdated approach — < cite index="17-1">a publicly accessible S3 bucket behind CloudFront can be hit directly by attackers, bypassing CloudFront's caching and protections entirely, which has led to real, costly billing spikes.</cite> OAC exists specifically to prevent this. - Forgetting to paste the bucket policy CloudFront generates.
Creating the OAC isn't enough by itself — S3 also needs to be told
to trust it. - Forgetting cache invalidation after updates. Your new files are
in S3, but edge locations may keep serving the old cached version
until you invalidate or the cache naturally expires. - Requesting the ACM certificate in the wrong region. If
CloudFront can't find your certificate in the dropdown, it's almost
always this.
Where to go from here
Once this is working, natural next steps: automate deployment with a
GitHub Actions workflow that uploads to S3 and invalidates the cache
on every push, add AWS WAF if you want extra protection, or move from
a hand-coded site to a static site generator for easier content
updates. All of them build on exactly the S3-plus-CloudFront
foundation you just set up.
GitHub Actions workflow that uploads to S3 and invalidates the cache
on every push, add AWS WAF if you want extra protection, or move from
a hand-coded site to a static site generator for easier content
updates. All of them build on exactly the S3-plus-CloudFront
foundation you just set up.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article