Part 1/42026-06-28

Deploying my portfolio to AWS - Part 1: from Next.js to CloudFront

First entry of the thread. I walk through the choice of AWS over simpler alternatives and leave the site running in production, with the bucket private and static-page routing fixed at the edge.

AWSCloudFrontS3JavaScriptWeb page

Introduction

This is the first post in a thread where I document the full journey of deploying my portfolio site on AWS infrastructure. I'm splitting it into small parts because every step has technical decisions that deserve some room to breathe.

This one covers the architectural choice, preparing Next.js for static export, and the first moves in AWS (IAM, S3 and CloudFront with OAC). By the end you'll have a working site served from a CloudFront URL.

Why AWS and not something like Vercel or Cloudflare?

Vercel and Cloudflare Pages are objectively simpler for deploying a Next.js site: connect your GitHub repo, pick a branch, done. That is the right answer for many cases, but I picked this path on purpose. My reasons for going with AWS:

  1. Transferable practice. I worked as a Cloud Support Engineer at AWS, so wiring up S3, CloudFront, ACM and Route 53 for real isn't new to me, and the muscle memory keeps paying off in other projects.
  2. Full control. If something breaks, I know exactly where to look.
  3. Predictable cost. The site is planned as a low-traffic environment sitting on the free tier, aiming for a bill of cents per month.

The chosen architecture

The resulting stack:

  • S3 as origin: holds the static bundle (the out/ folder Next.js emits).
  • CloudFront as CDN: caches globally, serves HTTPS, handles encryption.
  • Origin Access Control (OAC): CloudFront is the only thing allowed to read the bucket.
  • CloudFront Function: a tiny JavaScript that runs on every request to patch the Next.js routing.

(The custom domain with ACM is added in Part 3. In this part the site lives on the default CloudFront URL.)

Worth calling out the choice of OAC (Origin Access Control) over the older, now legacy OAI (Origin Access Identity). OAC supports SSE-KMS encryption and works in every region, including opt-in ones. For forward compatibility, always pick OAC.

The lifecycle of a request

[Visual note: a horizontal sequence diagram would work much better than the list. Three vertical lanes (Browser, CloudFront edge, S3) with numbered arrows between them, showing the "cache hit" as a shortcut that never reaches S3.]

When somebody visits a page on the site, this is the path it takes:

  1. The browser resolves via DNS the IP of a CloudFront edge close to the user.
  2. CloudFront receives the HTTPS request.
  3. The CloudFront Function runs at the edge: inspects and normalizes the URI (for example, /about/ becomes /about/index.html).
  4. CloudFront checks its local cache. If it has a valid, fresh response, it returns it straight away.
  5. On cache miss, CloudFront signs the request with SigV4 and forwards it to the origin (S3).
  6. S3 validates the signature against the bucket policy, finds the object, returns the content.
  7. CloudFront caches the response following its cache policy and serves it to the user.
  8. Later requests to that same URL hit the cache directly.

In my setup, after the first visitor to each route, almost everything is a cache hit. The content doesn't change between deploys. It only changes when I explicitly invalidate the cache.

Preparing Next.js for static export

Next.js defaults to running on a server (SSR). To generate pure static HTML you have to spell it out.

Change 1: next.config.mjs

const nextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
}
  • output: "export" is the main switch. trailingSlash: true makes routes end with a slash, which maps cleanly to the folder/index.html structure we'll have in S3.
  • images: { unoptimized: true } disables the next/image optimizer (which needs a runtime server). In my case I already use plain "img" tags.

What I give up going 100% static:

  • API routes. If I need a backend endpoint, I have to reach for something else (Lambda + API Gateway, or a managed service).
  • No runtime next/image optimization. Images are served as-is from /public. That forced me to manually compress photos (from 90MB down to 25MB) before deploying. For real production I could use an image CDN like Cloudinary or Imgix, but for a portfolio it's overkill.
  • No server-side middleware. Any runtime logic has to be client-side or move to CloudFront Functions.
  • Picking static vs dynamic up front saves you a lot of refactors later. For portfolios, institutional content, landing pages and low-frequency blogs, static wins on simplicity, cost and performance.

Change 2: generateStaticParams for dynamic routes

My site has routes like /projects/[slug] and /blog/[slug]. In static mode, Next.js needs to know ahead of time which slugs exist so it can prerender each one as a separate HTML.

The catch: my dynamic pages were client components ("use client"), and generateStaticParams can only be exported from server components. The fix was splitting each page into two files.

src/app/projects/[slug]/page.js (server wrapper):

import { projects } from "@/data/projects"
import ProjectDetailClient from "./ProjectDetailClient"

export function generateStaticParams() {
  return projects.map(p => ({ slug: p.slug }))
}

export default function Page({ params }) {
  return <ProjectDetailClient params={params} />
}

And src/app/projects/[slug]/ProjectDetailClient.js keeps being the original client component that uses useLanguage(), framer-motion, and so on.

AWS setup: IAM and CloudShell

Before deploying, two security decisions.

  1. Don't use the root user. I created a dedicated IAM user for deploys with managed policies (AmazonS3FullAccess and CloudFrontFullAccess). For real production I'd go least-privilege with custom policies, but for a personal portfolio the managed ones are fine. Practical reminder: the AWS root user should be reserved for account-management tasks (billing, closing the account). For any operational work, use IAM users or IAM roles.
  2. Create the S3 bucket as private.

S3 bucket configuration

SettingValue
Regionus-east-1
Block all public accessenabled
Versioningdisabled
Static website hostingnot enabled

Important: for compatibility you need us-east-1 with ACM when you add the custom domain later, because CloudFront certs must live there.

The old way was to enable static website hosting on S3 and point CloudFront to that public endpoint. That setup needs the bucket to be public, or close to it.

With OAC, the bucket stays fully private. Only CloudFront can read it, authenticated with signed requests. It's the architecture recommended by AWS since 2022.

Then, syncing the files up to the bucket:

aws s3 sync /tmp/<MY_REPO>/out s3://<MY_BUCKET> --delete

In my case that was around 250 objects and about 26 MiB, uploaded in roughly 30 seconds. Every route on the site ended up with its own index.html (thanks to trailingSlash: true):

s3://<MY_BUCKET>/index.html
s3://<MY_BUCKET>/about/index.html
s3://<MY_BUCKET>/blog/post-one/index.html
s3://<MY_BUCKET>/fotos/imagen.png

The --delete flag removes from the bucket any files no longer present locally, keeping the bucket mirrored to the local out/ folder. It's critical during updates; without it, you accumulate stale files forever.

CloudFront distribution with OAC

SettingValue
Origin<MY_BUCKET>.s3.us-east-1.amazonaws.com
Allow private S3 bucket accessenabled (this is OAC on auto-pilot)
Viewer protocol policyRedirect HTTP to HTTPS
Cache policyCachingOptimized
Default root objectindex.html

The "Allow private S3 bucket access to CloudFront" checkbox does under the hood what used to take four steps: it creates the OAC, wires the origin to use it, and applies the right bucket policy automatically:

{
  "Effect": "Allow",
  "Principal": { "Service": "cloudfront.amazonaws.com" },
  "Action": "s3:GetObject",
  "Resource": "arn:aws:s3:::<MY_BUCKET>/*",
  "Condition": {
    "ArnLike": {
      "AWS:SourceArn": "arn:aws:cloudfront::<MY_ACCOUNT_ID>:distribution/<MY_DIST_ID>"
    }
  }
}

That policy tells S3: allow GetObject only if the request is signed from this specific CloudFront distribution, from nobody else. Not anonymous users, not other CloudFront distributions, not even other services in my own account without explicit permissions.

Between five and fifteen minutes later, the distribution was deployed with a public URL assigned by AWS (following the https://dXXXXXXXX.cloudfront.net format).

I hit the root in the browser and the portfolio loads exactly as expected.

The documented trailing-slash limitation

Remember I set trailingSlash: true in next.config.mjs. Routes on my site look like /about/ or /blog/post-one/. In S3 they live as /about/index.html and /blog/post-one/index.html.

The Default root object: index.html I set in CloudFront only resolves the root (/ becomes /index.html). It doesn't apply to subdirectories, and this is a documented CloudFront limitation.

When a user goes to https://<MY_URL>/about/ this happens: CloudFront forwards the request to S3 unchanged (GET /about/), S3 has no object called about/ and returns 403, and CloudFront propagates the 403 back to the browser.

The result: the home loads, but no internal route works via direct URL. Navigating internally with Link does work (because Next.js intercepts), but if someone copies, pastes or refreshes, it breaks.

The fix: a CloudFront Function

CloudFront Functions are JS scripts with a restricted runtime that execute at the edge on every request. They're free up to two million invocations per month, so for this project's expected traffic they're effectively free forever.

My function:

function handler(event) {
    var request = event.request;
    var uri = request.uri;

    // /about/ becomes /about/index.html
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    }
    // /about becomes /about/index.html (also handles URLs without a slash)
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}

The logic: if the URI ends with a slash, append index.html. If it has no extension (.js, .png, etc.), I assume it's a page route and append /index.html. Anything else (static assets like images, JS chunks or fonts) passes through untouched.

What a CloudFront Function cannot do

Worth understanding the runtime restrictions before pushing more complex logic into it:

  • No network calls (no fetch, no DNS resolution). If you need to call APIs or external services from the edge, use Lambda@Edge.
  • No persistent storage. Every invocation is stateless; there's no way to keep state between requests.
  • Execution budget is about 1 millisecond of CPU. Enough to rewrite URIs, nowhere near enough for complex algorithms.
  • Memory is limited (around 2 MB).
  • The JS runtime is restricted: a subset of JavaScript, no async/await on cloudfront-js-1.0. Version 2.0 supports more.

For my case (rewriting the URI) everything fits with room to spare. If I needed anything fancier (JWT validation for auth, dynamic geo-blocking, A/B testing) I'd move to Lambda@Edge. Lambda@Edge as an alternative costs more per invocation and adds latency (5 to 30 ms, against under 1 ms for the function).

Tests run:

curl -I https://<MY_URL>/about/
# HTTP/2 200

curl -I https://<MY_URL>/blog/<post-one>/
# HTTP/2 200

All working.

Caching strategy and invalidations

CloudFront defaults to the CachingOptimized cache policy, which respects Cache-Control headers from the origin (S3 here), caches for 24 hours objects without those headers, and automatically compresses with gzip or brotli when the browser accepts it.

For a static site this works well because of the Next.js naming pattern: critical assets (JS chunks, CSS, fonts) carry a hash in the filename that changes on every build. When I push a new version, old files stay accessible by their hash, but the HTML pages reference the new ones.

What I do need to invalidate are the HTML files, which have no hash and should reflect updates immediately:

aws cloudfront create-invalidation \
  --distribution-id <MY_DIST_ID> \
  --paths "/*"

AWS gives you 1000 invalidation paths free per month. The /* wildcard counts as a single path. For one or two deploys a month I'm nowhere near the limit. Past 1000, it's 0.005 USD per additional path (half a cent).

Wrapping up

At this point the portfolio is publicly available on CloudFront, with free HTTPS (via the default *.cloudfront.net cert AWS provides), cached globally and served from a private S3 bucket.

The key takeaways from this first part:

  • trailingSlash: true saves you headaches if you're heading to S3. Without it, slash-less URLs clash with how S3 expects objects.
  • OAC beats OAI. If you still see tutorials pointing at OAI, ignore them for new deploys.
  • The new wizard's "Allow private S3 bucket access" saves you about 30 minutes of manual steps compared to the old flow (create OAC, wire origin, hand-write the bucket policy).
  • CloudFront Functions have very tight runtime limits. Knowing them up front avoids refactors later.
  • Default root object only works for the root, not for subdirectories. For SPA-style routing you need the CloudFront function or custom error responses.

On cost: while inside the free tier, this costs essentially nothing. Outside the free tier, excluding the domain, the bill is under 1 USD per month (S3 storage for a few cents, and CloudFront covered by its always-free layer of 1 TB of transfer and 10 million requests per month).

Coming up in Part 2

  • Custom error responses so missing URLs show a clean 404 page with the correct HTTP code.
  • deploy.sh: a script that automates build, sync and invalidation, turning an update into a single command.