Filter concepts by levelShowing all levels.

AWS · Section 57

AWS for Full-Stack / React Applications

Level
intermediate
Read
35 min
Concepts
3

A React build produces either a folder of files or a server, and that single fact decides the whole deployment. Files go into a private S3 bucket behind CloudFront using origin access control — which AWS recommends over the legacy origin access identity, and which requires S3 Object Ownership set to bucket owner enforced — with 403 and 404 mapped to /index.html so client-side deep links survive a refresh. A server means real compute on ECS or Lambda, with cold starts, a deploy and a scaling policy to own, and it is worth taking on only for the routes that genuinely render per request. Between the build and a working site sits a short configuration list that fails in predictable ways: alias records rather than CNAMEs at the apex, an ACM certificate that must be requested in us-east-1 to serve viewers through CloudFront and must cover the alternate domain name in its SAN field, content-hashed assets cached for a year and never invalidated against an entry point that is, and an API served as a /api/* behavior on the same distribution so there is no CORS preflight and no cross-site cookie at all. Environments are separated by account rather than by naming convention, because the blast radius of a frontend pipeline is exactly what its credentials can reach. Finally the whole path is worth drawing twice — shell from the edge, data through the ALB to the backend and database — because that drawing becomes the search order when something intermittent breaks, and because it makes obvious the one edge misconfiguration that is silent and severe: caching a behavior whose cache key does not include the credential.

What is true here

  1. The build output — files or a server — decides whether there is any compute at all.
  2. The bucket stays private; CloudFront reaches it with origin access control.
  3. The CloudFront certificate lives in us-east-1, whatever Region the workload is in.
  4. A same-origin /api/* behavior removes CORS and cross-site cookies entirely.
  5. Never cache an authenticated behavior — the cache key would omit the user.

What you will be able to do

  • Choose between static output and server rendering for a given set of routes
  • Serve a single-page application from a private bucket with working deep links
  • Configure DNS, certificates and cache behavior correctly the first time
  • Lay out environments so a pipeline mistake cannot reach production
  • Trace a user-reported failure to a single hop in the request path
From build output to a working request path
determinesthe originsdeterminesthe hops

What does the build produce?

files, or a server

Configure the edge

DNS, certificate, caching, CORS, environments

Draw the whole path

shell from the edge, data through the backend

  • What does the build produce? — files, or a server
    • leads to Configure the edge (determines the origins)
  • Configure the edge — DNS, certificate, caching, CORS, environments
    • leads to Draw the whole path (determines the hops)
  • Draw the whole path — shell from the edge, data through the backend

AWS for Full-Stack / React Applications

Choosing between static delivery and server rendering, the DNS, certificate, caching and CORS configuration that surrounds it, and the full request path from browser to database.

Static Output or Server Rendering

coreintermediate

A React build produces either a folder of files or a server. If it is a folder — plain React, or Next.js exported statically — it goes in a private S3 bucket behind CloudFront and there is nothing to run. If the app renders pages per request, something has to execute that code: a container behind a load balancer, or a function at the edge. Which one you have is decided by the framework configuration, not by preference.

Think of it as

Ask one question of every route: can this page be built before anyone asks for it? If yes for all of them, you have a static site and no compute at all. If no for some of them, you have a server, and the static path is still worth keeping for everything else — most applications are mostly static with a few dynamic routes.

What we're doing: Serve a single-page application from a private bucket so that deep links do not 404.

spa-on-cloudfront.txttext
The problem: the app routes /orders/8821 in the browser, but
that key does not exist in S3. S3 returns 403 (with Block Public
Access) or 404, and the user sees an error on refresh — while the
same link works fine when clicked from inside the app.

Why: client-side routing is a browser concept. The first request
for a deep link is a real HTTP request for a file that was never
built.

The fix, in the distribution rather than the bucket:
  Custom error response  403 → /index.html, status 200
  Custom error response  404 → /index.html, status 200

  CloudFront now returns the app shell for any unknown path,
  the router reads the URL, and renders the right view.

Keep the bucket private throughout:
  Block Public Access ON
  Object Ownership: Bucket owner enforced
  Origin access control, signing behaviour "always"
  Bucket policy allows cloudfront.amazonaws.com with a condition
  on AWS:SourceArn = this distribution's ARN
1
This is the first thing that breaks on every SPA deployment, and it is not a bug in the app.
8
Mapping to 200 rather than passing the error through is what stops search engines and monitors treating every page as broken.
16
AWS recommends OAC over the legacy OAI, and OAC needs Object Ownership set to bucket owner enforced — the default for new buckets.

Why this works: Serving a SPA is two decisions that are easy to get half-right: the bucket must stay private and reachable only through CloudFront, and CloudFront must answer unknown paths with the app shell. Making the bucket public "so the links work" solves the second by abandoning the first, and it is the single most common way a static site becomes a data-exposure incident.

Making the bucket a public website endpoint to get SPA routing

Wrong

text
# S3 static website hosting on, error document = index.html,
# Block Public Access off, bucket policy allows s3:GetObject to *.

Better

text
# Regular S3 bucket origin, private, OAC with signing "always".
# SPA routing via CloudFront custom error responses 403/404 → 200.

What you see: The bucket is readable directly over the internet, bypassing CloudFront entirely — so WAF rules, signed URLs, logging and cache policies all apply to a path nobody is required to use.

Why: A bucket configured as a website endpoint has to be attached to CloudFront as a custom origin, which means it cannot use OAC or OAI and must therefore be public. Every control you attach to the distribution is then optional for an attacker who addresses the bucket directly.

Two builds, two operational bills

Static output — S3 + CloudFront

  • +The build produces files; nothing executes at request time
  • +CloudFront serves from edge caches, origin is rarely touched
  • +Scales with no configuration and costs nothing while idle
  • +Deploy is a sync plus an invalidation of the changed paths
  • +Cannot do per-request server logic, secrets, or SSR data fetching

Server rendering — ECS or Lambda

  • The build produces a server; code runs on every uncached request
  • Needed for SSR data fetching, per-user pages, server actions
  • Has cold starts (Lambda) or a warm fleet to pay for (ECS)
  • Deploy is a rolling release with health checks and rollback
  • Still put CloudFront in front, and still serve assets from S3
  • Static output — S3 + CloudFront
    • The build produces files; nothing executes at request time
    • CloudFront serves from edge caches, origin is rarely touched
    • Scales with no configuration and costs nothing while idle
    • Deploy is a sync plus an invalidation of the changed paths
    • Cannot do per-request server logic, secrets, or SSR data fetching
  • Server rendering — ECS or Lambda
    • The build produces a server; code runs on every uncached request
    • Needed for SSR data fetching, per-user pages, server actions
    • Has cold starts (Lambda) or a warm fleet to pay for (ECS)
    • Deploy is a rolling release with health checks and rollback
    • Still put CloudFront in front, and still serve assets from S3

What each rendering mode actually needs from AWS

What each rendering mode actually needs from AWS
ModeRuns atAWS shapeWatch for
Client-side rendered SPABuild time onlyS3 + CloudFront, OAC, SPA error responsesDeep links 404 without the error-response mapping
Statically exported pagesBuild time onlyS3 + CloudFrontContent changes need a rebuild, not just a cache purge
Server-side rendered pagesEvery requestECS behind an ALB, or LambdaCold starts, and a runtime that now needs secrets and IAM
Incremental regenerationBuild time, then in the backgroundCompute plus shared storage for the regenerated pagesTwo tasks regenerating the same page independently
API routesEvery requestSame compute as SSR, or a separate serviceThey are a backend — they need the same review as one

Together

text
# One distribution, two origins — the common hybrid
Behavior  /_next/static/*   → origin: S3     cache: 1 year, immutable
Behavior  /images/*         → origin: S3     cache: long
Behavior  /api/*            → origin: ALB    cache: disabled
Behavior  /*  (default)     → origin: ALB    cache: by policy

# Assets never reach the application. Only rendered HTML does.

Remember: If the build produces files, ship them from a private S3 bucket behind CloudFront with OAC, and map 403/404 to /index.html with a 200 so deep links work. If it produces a server, run it on ECS or Lambda — but keep static assets on the S3 origin, and only render the routes that actually need rendering.

See also: s3 and alb origin patterns · public access risks · dns tls caching and cors · three tier web application

DNS, TLS, Caching, CORS, and Environments

coreintermediate

Between a built frontend and a working site sits a short list of configuration that is easy to get wrong once and then never think about again: which name resolves where, which certificate is valid, what gets cached and for how long, whether the browser is allowed to call the API, and how staging is kept apart from production. Each of these has one common failure and one correct answer.

Think of it as

Decide whether the API is same-origin or cross-origin first, because that one choice determines whether CORS exists at all, whether cookies work without extra flags, and whether you need one distribution or two. Serving the API under the same domain as a second CloudFront behavior removes an entire class of problems rather than configuring around them.

What we're doing: Keep three environments apart so a deploy to staging cannot touch production.

environments.txttext
Weak separation — one account, names as the only boundary:
  acme-web-dev, acme-web-staging, acme-web-prod   (three buckets)
  One distribution per environment, one IAM role for CI.
  A typo in a bucket name in a pipeline reaches production.

Strong separation — one account per environment:
  dev      111122223333   dev.example.com
  staging  444455556666   staging.example.com
  prod     777788889999   example.com

  CI assumes a role in one account per stage. Credentials for
  production are not present in the staging pipeline at all.

What stays per-environment either way:
  - its own hosted zone or subdomain, and its own certificate
  - its own distribution, so cache settings can differ
  - its own API origin, so staging never calls production data
  - non-secret build-time config injected at build, not baked
    into the repository
1
Name-based separation fails to exactly one thing: a mistake. That is the case separation exists to cover.
6
The account boundary is the strongest isolation AWS offers, and it is what makes a wrong pipeline harmless rather than catastrophic.
13
Frontend build-time configuration is public by definition — it ships in the bundle. Anything secret has to be read server-side.

Why this works: Frontends are deployed far more often than backends, usually by more people, and usually with a pipeline that has write access to a bucket and a distribution. That combination makes the blast radius of a mistake entirely a function of what the pipeline's credentials can reach — which is an account boundary question, not a naming convention.

Baking an API base URL into the bundle at build time and hoping it is configurable

Wrong

text
// Built once, promoted between environments:
const API = "https://api-staging.example.com";  // now permanent

Better

text
// Relative path — the same bundle works in every environment,
// because CloudFront routes /api/* to the right origin.
const API = "/api";

What you see: The artifact tested in staging cannot be promoted to production, so production runs a bundle that was never tested anywhere.

Why: Anything injected at build time is fixed in the artifact, which breaks build-once-deploy-many. A relative path defers the decision to the distribution, so one bundle is genuinely promotable and the environment is a property of where it is served from rather than of how it was built.

One domain, two behaviors — and where each piece of configuration lives

Everything the browser talks to is one origin, so there is no CORS preflight and no cross-site cookie. The split into two backends happens inside CloudFront, invisibly to the browser.

  • A diagram showing a browser making two kinds of request to a single domain.
  • Route 53 holds an alias record for app.example.com pointing at one CloudFront distribution. The ACM certificate for that name must live in us-east-1.
  • Inside the distribution, two cache behaviors: the path pattern /api/* forwards to an ALB origin with caching disabled, and the default path pattern serves the private S3 bucket via origin access control with long caching.
  • A note beneath the assets path: hashed filenames are cached for a year and never invalidated; only index.html is invalidated on deploy.
  • A note beneath the API path: because both share one origin name, the browser sends no preflight and cookies are first-party.

The seven pieces, the usual mistake, and the setting that fixes it

The seven pieces, the usual mistake, and the setting that fixes it
PieceUsual mistakeWhat to do instead
DNSCNAME at the zone apex, which is not validRoute 53 alias record — works at the apex and needs no second lookup
TLS certificateCertificate requested in the workload's RegionRequest or import it in us-east-1 for CloudFront; any Region for an ALB origin
Alternate domain nameName not present in the certificate's SAN fieldExact match, or a wildcard at the same level as the name being added
CDN cachingOne TTL for everything, or caching disabled everywhereLong and immutable for hashed assets, none for API paths, short for the HTML entry point
InvalidationInvalidating `/*` on every deployInvalidate the entry point only; hashed filenames make the rest unnecessary
CORSAPI on a second domain, then debugging preflightsServe the API as a `/api/*` behavior on the same distribution
Environment separationOne account, names distinguished by a prefixSeparate accounts per environment, separate zones, separate distributions

Together

text
# Cache-Control, per kind of file — the settings that make
# invalidation almost unnecessary
/index.html          no-cache            (revalidate every time)
/assets/*.a91f3c.js  max-age=31536000, immutable
/assets/*.7b2e91.css max-age=31536000, immutable
/api/*               no-store

# Deploy: upload new hashed assets, overwrite index.html,
# invalidate exactly one path: /index.html

Same-origin API versus a separate API domain

Same-origin API versus a separate API domain
Aspect/api/* behavior, same domainapi.example.com, separate domain
Preflight requestsNone — it is same-originOPTIONS before most non-simple requests
CookiesFirst-party, plain `Secure; HttpOnly`Cross-site: needs `SameSite=None`, and is subject to browser tracking rules
CertificatesOne certificate, one distributionTwo names to cover and renew
Local developmentDev server proxy mirrors the production path splitDev and production differ in origin, so CORS bugs appear only in production
Reach for it whenThe default for a first-party frontend and APIThe API is a genuinely separate public product with its own consumers

Together

text
# Access-Control-Allow-Origin: * and credentials do not combine.
# A browser rejects a credentialed response whose allowed origin
# is the wildcard — so a cross-origin API using cookies must echo
# one exact origin and set Access-Control-Allow-Credentials: true.
#
# The same-origin layout removes the entire question.

Remember: Alias records at the apex, ACM certificate in us-east-1 for CloudFront, hashed assets cached for a year and never invalidated, only the entry point invalidated on deploy, the API served as a same-origin `/api/*` behavior so CORS never arises, and one account per environment.

See also: cache keys ttl and invalidation · route53 vocabulary · environment separation · frontend to backend request path

The Full Request Path, End to End

coreadvanced

A full-stack design is one path drawn twice: once for loading the application, and once for the calls it makes afterwards. Both start at the same domain and diverge at CloudFront — the first is answered from cache or S3, the second travels to the load balancer, the backend, and the database. Being able to draw both, and name what fails at each hop, is the whole skill.

Think of it as

Every hop is a place a request can stop. Walk the path and ask what a failure looks like at each one: a DNS answer that is stale, a cache that serves an old build, a certificate that does not match, a 401 from the API, a connection pool that is full. Designing the path is choosing where you want failures to be visible.

What we're doing: Trace one user-reported failure from the browser to the exact hop that produced it.

trace-a-failure.txttext
Report: "The orders page spins forever, but only sometimes."
The browser network tab shows GET /api/orders pending for 30s,
then failing. Everything else on the page loads instantly.

Hop 1 — CloudFront. Assets load, so DNS, TLS and the distribution
are healthy. The failure is on the /api/* behavior only. That
already eliminates half the path.

Hop 2 — ALB. Target group shows 4 of 4 healthy, and the ALB's
own metrics show the request was routed. So it reached a task.

Hop 3 — Backend. The request id from the failing response appears
in CloudWatch Logs. The log line says the handler started and
never logged completion — no exception, no 500.

Hop 4 — Database. The backend's connection-pool metric is at its
maximum for the same window, and the pool wait time matches the
30 seconds exactly.

Conclusion: not a network fault, not an application bug. The pool
is exhausted, and requests are waiting for a connection that a
long-running report query is holding.
1
"Only sometimes" plus "everything else loads" is already a strong signal that the asset path is fine and the data path is not.
5
Eliminating hops is faster than inspecting them — each answer removes a section of the path.
12
A handler that starts and never finishes points at a wait, not a crash. That is a different search than a stack trace.
18
The matching 30-second figure is what turns a plausible theory into the answer.

Why this works: The value of drawing the path is that it becomes a search order. Without it, an intermittent failure is investigated by guessing; with it, each hop is either eliminated or implicated by one observation, and the number of remaining candidates falls at every step. That is also why each hop needs to emit something observable — an unlogged hop cannot be eliminated.

Letting the browser talk to two different origins for assets and data

Wrong

text
// app.example.com serves the frontend
// api.example.com serves the API, with CORS and SameSite=None

Better

text
// app.example.com serves both:
//   /*      → S3 origin
//   /api/*  → ALB origin

What you see: A preflight on every mutating request, cookies that behave differently across browsers, and a class of bug that only appears in production because local development proxies both through one port.

Why: Two origins add a browser-enforced protocol between the frontend and its own backend, and that protocol has to be configured identically across every environment or it fails only in some of them. Splitting inside CloudFront gets the same backend separation without introducing the boundary into the browser.

Load the app, then call the API — two paths, one domain
Browser
Route 53
CloudFront
ALB
Backend
RDS
  1. 1. resolve app.example.comAlias record answers with the distribution
  2. 2. CloudFront address
  3. 3. GET / (the app shell)
  4. 4. index.html + hashed assetsServed from the edge cache or the private S3 origin
  5. 5. GET /api/orders + cookie or bearer tokenSame origin, so no preflight
  6. 6. forward, caching disabledAuthorization header forwarded
  7. 7. route by listener rule to a healthy target
  8. 8. query, from a pooled connectionPrivate subnet; sg-rds allows sg-app only
  9. 9. rows
  10. 10. 200 with JSON, and the request id
  1. Browser → Route 53: resolve app.example.com (Alias record answers with the distribution)
  2. Route 53 → Browser: CloudFront address
  3. Browser → CloudFront: GET / (the app shell)
  4. CloudFront → Browser: index.html + hashed assets (Served from the edge cache or the private S3 origin)
  5. Browser → CloudFront: GET /api/orders + cookie or bearer token (Same origin, so no preflight)
  6. CloudFront → ALB: forward, caching disabled (Authorization header forwarded)
  7. ALB → Backend: route by listener rule to a healthy target
  8. Backend → RDS: query, from a pooled connection (Private subnet; sg-rds allows sg-app only)
  9. RDS → Backend: rows
  10. Backend → Browser: 200 with JSON, and the request id

Every hop, what it does, and what its failure looks like

Every hop, what it does, and what its failure looks like
HopIts jobWhat failure looks like
Route 53Answer the name with the distributionSite unreachable everywhere at once; recent record change is the first suspect
CloudFrontServe assets, forward API calls, terminate TLSCertificate warning, or an old build served after a deploy
S3 origin (OAC)Hold the built files, privately403 on every asset — usually the bucket policy or the OAC signing setting
ALBRoute to a healthy backend target503 with zero healthy targets; check the health check first
BackendAuthorize, apply logic, query401 or 403 on a valid session, or a 500 in application logs
Connection poolReuse database connectionsRequests queue and time out while the database itself is idle
RDSAnswer the querySlow queries in Performance Insights; latency shows up first at the backend

Together

text
# Walk the path top-down when something breaks, and stop at the
# first hop that answers differently from the one above it.
dig  app.example.com          → does the name resolve, and to what?
curl -I https://app.example.com  → does the shell load, from which cache?
curl -i https://app.example.com/api/health  → does the API answer?
# ALB target group health → backend logs → database metrics.
# Each step narrows the search to one hop.

Where the session lives, and what each choice costs

Where the session lives, and what each choice costs
PropertyHttpOnly cookie (same-origin API)Token in localStorage
Readable by page scriptsNoYes — any injected script can take it
Sent automaticallyYes, by the browserNo — the app must attach it to every call
Cross-site flags neededNo, if the API is same-originNot applicable
CSRF exposureYes — needs a token or SameSite protectionNo, because nothing is sent automatically
Works with a static frontendYesYes
Default choiceCookie, with a same-origin API pathOnly when the API is genuinely cross-origin and third-party

Together

text
# The same-origin cookie, in full
Set-Cookie: session=…; Secure; HttpOnly; SameSite=Lax; Path=/

# Secure   → HTTPS only
# HttpOnly → invisible to document.cookie, so XSS cannot read it
# SameSite=Lax → not sent on cross-site POSTs, covering most CSRF
# No Domain attribute → host-only, not shared with subdomains

Remember: Draw the path twice: shell from the edge, data through the ALB to the backend and database. Keep both on one origin so there is no CORS, disable caching on authenticated behaviors and forward the Authorization header, and make sure every hop emits something so a failure can be narrowed to one of them.

See also: dns tls caching and cors · three tier web application · the fixed investigation order · public to private design

Advertisement