THE DEV SPECTRUM

Back to Spectrum

S3 + CloudFront vs Nginx + Docker: Where Should You Host a React App?

12 min read #DevOps PK Pramod Krishna

Have you ever looked at a production React application and wondered:

If React becomes just HTML, CSS, and JavaScript after npm run build, why are we putting it inside a Docker container?

That's a good question.

In the previous article, we saw that a typical React/Vite application can be built using:

npm run build

which produces something similar to:

dist/
├── index.html
└── assets/
    ├── index-Bx73a91.js
    ├── index-D2x91s.css
    └── logo-C91ab3.svg

These are static files.

That means we could upload them to Amazon S3, place CloudFront in front of the bucket, and serve the application globally.

Something like:

Users


CloudFront


Amazon S3

  ├── index.html
  ├── app.js
  ├── styles.css
  └── images/

Simple.

But then you join another project and see this:

Users


Load Balancer


ECS / Kubernetes


Docker Container


Nginx


React Static Files

Now we seem to have added:

  • Docker
  • Nginx
  • containers
  • a container registry
  • ECS or Kubernetes
  • load balancers
  • health checks
  • CPU and memory
  • autoscaling

just to serve some .html, .js, and .css files.

Why?

Let's understand what each architecture is actually solving.


First, Remember Where React Actually Runs

Before comparing the architectures, there's one concept we need to keep clear.

For a typical client-side React application:

React executes in the user's browser.

Suppose our production build contains:

index.html
assets/app.js
assets/app.css

The server sends these files to the browser.

Server

   │ HTML + CSS + JavaScript

Browser


JavaScript executes


React runs

Neither S3 nor Nginx is "running React."

They are simply delivering the files required by the browser.

This means the fundamental requirement is surprisingly simple:

We need something capable of delivering static files over HTTP.

Both S3 + CloudFront and Nginx can do that.

They just do it differently.


Option 1: S3 + CloudFront

Let's start with the simpler architecture.

After our CI/CD pipeline builds the application:

npm ci
npm run build

we upload the resulting files to S3.

Git Repository

      │ git push

CI/CD Pipeline

      ├── npm ci
      └── npm run build


    dist/

      │ upload

Amazon S3


CloudFront


    Users

S3 stores the files.

CloudFront acts as the Content Delivery Network, or CDN.


Why Put CloudFront in Front of S3?

You might wonder:

Why not just let users access S3 directly?

Technically, there are ways to serve websites from S3.

But CloudFront gives us several important capabilities.

For example:

  • CDN caching
  • HTTPS
  • custom domains
  • edge locations
  • compression
  • cache policies
  • security controls
  • integration with AWS WAF
  • reduced traffic to the origin

Instead of every user requesting:

S3 → app.js
S3 → styles.css
S3 → logo.svg

CloudFront can cache those assets closer to users.

Conceptually:

                     ┌── User

                ┌────▼─────┐
                │ CDN Edge │
                └────┬─────┘

                     │ Cache miss

Users ──► CloudFront ──► S3


                Cache assets

The first request may reach S3.

Subsequent requests can often be served directly from CloudFront.


Static Assets Are Perfect for CDNs

Remember the filenames generated by modern frontend build systems?

index-Bx73a91.js
styles-C81ab92.css

These hashes are extremely useful.

Suppose we deploy version 1:

index-Bx73a91.js

We can tell CloudFront and browsers:

Cache this file for a long time.

Then we deploy version 2:

index-Xa82b14.js

The URL changed.

So browsers automatically request the new file.

This makes static frontend assets excellent candidates for aggressive caching.


There Is No Frontend Server to Maintain

This is one of the biggest architectural differences.

With S3 + CloudFront, there isn't necessarily a continuously running frontend application server.

There is no EC2 instance sitting there waiting for requests.

There isn't a Node.js process.

There isn't an Nginx process that you manage.

There isn't a frontend container that must remain healthy.

Conceptually:

S3

  │ stores objects

CloudFront

  │ distributes objects

Browser

This removes an entire category of operational concerns.


Option 2: Nginx + Docker

Now let's look at the architecture many developers encounter in containerized environments.

A typical production Dockerfile might look like this:

FROM node:22-alpine AS build

WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .
RUN npm run build


FROM nginx:alpine

COPY --from=build /app/dist /usr/share/nginx/html

This is called a multi-stage Docker build.

The first stage:

Node.js


npm ci


npm run build


dist/

builds the application.

The second stage takes only the generated files:

dist/


Nginx Image


Final Docker Image

The final production container does not necessarily contain:

Node.js
npm
Vite
source files

It might contain only:

Nginx
+
HTML
+
CSS
+
JavaScript

This is already much better than running npm run dev.


What Is Nginx Actually Doing?

Nginx is acting as the web server.

When the browser requests:

GET /

Nginx might return:

/usr/share/nginx/html/index.html

When the browser requests:

GET /assets/index-Bx73a91.js

Nginx returns:

/usr/share/nginx/html/assets/index-Bx73a91.js

That's basically it.

Nginx doesn't understand your React components.

It doesn't execute:

function App() {
  return <h1>Hello</h1>;
}

That JavaScript eventually executes inside the user's browser.

Nginx is simply a very capable HTTP server.


Then Why Put Nginx Inside Docker?

This is where the question becomes more architectural than technical.

The reason is often not:

"React requires Docker."

It doesn't.

The reason is often:

"Our platform deploys applications as containers."

Imagine a company where everything runs on Kubernetes:

Backend A ──► Container
Backend B ──► Container
Worker    ──► Container
Frontend  ──► Container

They already have:

  • Kubernetes
  • container registries
  • deployment pipelines
  • Helm charts
  • ingress controllers
  • monitoring
  • logging
  • autoscaling
  • security scanning

For that organization, packaging the frontend as:

frontend:1.4.2

may fit naturally into the existing deployment model.

The question isn't necessarily:

What is the absolute minimum infrastructure required to host React?

Instead, it may be:

What deployment model fits our existing platform?

Those can lead to different answers.


The Same Applies to ECS

Suppose a company already runs everything on Amazon ECS.

Their architecture might be:

                    Internet


               Application Load
                   Balancer

              ┌────────┴────────┐
              ▼                 ▼
         /api/*              /*
              │                 │
              ▼                 ▼
        Backend ECS        Frontend ECS
          Service             Service
              │                 │
              ▼                 ▼
          Node.js             Nginx


                              React files

This has an interesting advantage.

One load balancer can route:

/api/* → backend

/* → frontend

Everything is part of the same container platform.

That can simplify certain organizational and networking patterns even though it requires more infrastructure than static hosting.


Docker Also Gives Us a Deployable Artifact

There's another reason teams like containerized frontends.

Consider a Docker image:

my-frontend:2.3.1

That image contains exactly what we want to deploy.

We can promote the same artifact through environments:

Build


frontend:2.3.1

  ├──► Development

  ├──► UAT

  └──► Production

This can make deployment and rollback straightforward.

For example:

Production

frontend:2.3.1

      │ problem detected


rollback


frontend:2.3.0

Container platforms are built around this idea of immutable deployable artifacts.


But Static Hosting Can Do Versioned Deployments Too

Docker doesn't have a monopoly on versioning.

We could structure S3 deployments like:

s3://my-frontend/

releases/
├── 2.3.0/
├── 2.3.1/
└── 2.4.0/

Or let our CI/CD system manage deployments and rollbacks.

So containerized deployment is not required for version control.

It is simply a deployment model many organizations already understand and operate.


Runtime Configuration Is Another Interesting Difference

This is one area where frontend deployments sometimes become confusing.

Suppose our React application needs an API URL:

https://api.example.com

With Vite, we might configure:

VITE_API_URL=https://api.example.com

and build the application.

The important thing to understand is that frontend environment variables are often injected at build time.

For example:

VITE_API_URL


npm run build


JavaScript bundle


https://api.example.com

The value effectively becomes part of the generated JavaScript.

So if we build:

frontend:2.3.1

with:

VITE_API_URL=https://api-dev.example.com

we cannot necessarily take the same static files and magically change them by setting:

VITE_API_URL=https://api-prod.example.com

when starting the container.

The application has already been built.


Runtime Configuration With Nginx

Some teams solve this using runtime-generated configuration.

For example, the application might load:

/config.js

containing:

window.APP_CONFIG = {
  API_URL: "https://api.example.com"
};

When the container starts, an entrypoint script generates this file using environment variables.

Conceptually:

Docker Image

     │ container starts

Environment Variables


Entrypoint Script


config.js


Nginx


Browser

Now the same image could potentially run in multiple environments:

frontend:2.3.1

      ├── DEV
      │    API_URL=dev-api

      ├── UAT
      │    API_URL=uat-api

      └── PROD
           API_URL=prod-api

That's one reason containerized frontend deployments can be attractive.

But importantly:

This is an architectural pattern built around static files. React itself still doesn't require Nginx or Docker.


Can We Do Runtime Configuration With S3?

Yes.

For example, we could deploy:

index.html
assets/
config.json

and have the application request:

fetch("/config.json")

The configuration could contain:

{
  "apiUrl": "https://api.example.com"
}

Different environments could provide different versions of config.json.

So again, this capability isn't exclusive to containers.

The implementation is simply different.


What About Client-Side Routing?

There's another common issue you'll encounter with React applications.

Suppose React Router defines:

/dashboard
/users
/settings

When navigating inside the application, everything works.

But then someone directly enters:

https://example.com/dashboard

into their browser.

The server receives:

GET /dashboard

But there isn't actually a file called:

dashboard

Our application only has:

index.html

React Router handles /dashboard after the application loads in the browser.

Therefore, our hosting layer usually needs a fallback:

/dashboard


index.html


React loads


React Router sees /dashboard


Dashboard component

With Nginx, we might configure:

location / {
    try_files $uri $uri/ /index.html;
}

With CloudFront/S3, we need to configure the equivalent SPA routing behavior appropriately.

This is not really an Nginx-vs-S3 problem.

It's a consequence of client-side routing.


What About Caching?

Both architectures can cache static files.

Nginx can return headers such as:

Cache-Control: public, max-age=31536000, immutable

CloudFront can also cache assets for long periods.

But CDN-based architectures have an important advantage:

User in India


Nearby CDN Edge

instead of every request needing to travel all the way to your application infrastructure.

Of course, you can also put CloudFront in front of an ALB and Nginx.

Then the architecture becomes:

Users


CloudFront


ALB


ECS


Nginx


React files

This works.

But now it's worth asking:

If CloudFront is caching static files anyway, what value is ECS providing for this particular frontend?

Sometimes there is a good answer.

Sometimes there isn't.

Architecture should be intentional.


Let's Compare the Two Approaches

For a normal client-side React SPA, the two architectures might look like this.

S3 + CloudFront

               Internet


             CloudFront

              Cache Hit?
               /       \
             Yes        No
              │          │
              ▼          ▼
           Return       S3
           Asset         │

                       Asset

Nginx + Docker

               Internet


                  ALB

            ┌──────┴──────┐
            ▼             ▼
       Container      Container
            │             │
          Nginx         Nginx
            │             │
            ▼             ▼
       Static Files   Static Files

Notice something important.

With ECS/Kubernetes, we often create multiple copies of the exact same static files.

Container 1 → app.js
Container 2 → app.js
Container 3 → app.js
Container 4 → app.js

That's necessary for availability and scaling of the HTTP servers.

With object storage:

           S3

        one object


       CloudFront
      /    |    \
   Edge   Edge   Edge

the architecture is fundamentally different.


Operational Complexity

This is where the difference becomes significant.

With S3 + CloudFront, we generally think about:

S3 bucket
CloudFront distribution
DNS
TLS certificate
cache policies
CI/CD

With ECS, we may need:

Docker image
ECR repository
ECS cluster
task definition
ECS service
CPU
memory
desired count
autoscaling
health checks
ALB
target groups
security groups
logging
container scanning
CI/CD

With Kubernetes, the list can grow further:

Docker image
registry
Deployment
Pods
Service
Ingress
ConfigMap
Secrets
resource requests
resource limits
HPA
health probes
cluster management

This doesn't mean containers are bad.

It means containers introduce capabilities and operational responsibilities.

The important question is whether your application actually benefits from those capabilities.


Failure Modes Are Different Too

Suppose you're running two frontend containers:

ALB

 ├── Container A

 └── Container B

Container A can:

  • crash
  • fail its health check
  • run out of memory
  • fail to start
  • have an Nginx configuration error

The container platform needs to detect and recover from these failures.

With S3:

CloudFront


   S3

there isn't an application process for you to keep alive.

That is a significant simplification.


What About Cost?

For small and medium static applications, S3 + CloudFront can be very cost-efficient because you're primarily paying for:

  • storage
  • requests
  • CDN traffic

You don't need to keep compute running continuously just to serve static assets.

With containerized hosting, you may be paying for compute continuously:

Container 1 → CPU + Memory
Container 2 → CPU + Memory
Load Balancer
Container Platform

even during periods with very little traffic.

At large scale, actual cost comparisons become more nuanced and depend on traffic patterns, regions, caching, transfer, platform commitments, and existing infrastructure.

So cost should be measured rather than assumed.

But conceptually, static hosting avoids paying for a continuously running web-server compute layer.


So Why Do Companies Still Use Docker for React?

At this point you might think:

Then putting React inside Docker is pointless.

Not necessarily.

There are legitimate reasons teams choose it.

A company may already have a standardized Kubernetes or ECS platform.

Their deployment pipeline might expect every application to produce:

Docker Image

Their operational model might already provide:

Logging
Monitoring
Security
Deployment
Rollback
Service discovery
Configuration
Secrets
Networking

through the container platform.

Having:

Frontend → Docker
Backend  → Docker
Worker   → Docker

can provide organizational consistency.

Sometimes platform consistency is more valuable than minimizing the number of infrastructure components.


When Does S3 + CloudFront Make Sense?

For a typical client-side React/Vite application, static hosting is a strong fit when the application:

  • produces static files
  • doesn't require server-side rendering
  • doesn't require a persistent application server
  • can call APIs separately
  • benefits from CDN caching
  • needs global distribution
  • should have minimal infrastructure

The architecture becomes:

                    ┌──────────────┐
                    │   Frontend   │
                    │              │
User ─► CloudFront ─►      S3      │
                    └──────────────┘

                           │ API requests

                    ┌──────────────┐
                    │   Backend    │
                    │              │
                    │ API Gateway  │
                    │     ALB      │
                    │     ECS      │
                    │   Lambda     │
                    └──────────────┘

The frontend and backend don't have to use the same hosting technology.

That's an important architectural idea.


When Does Nginx + Docker Make Sense?

Containerized frontend hosting can make sense when:

  • your organization standardizes deployments around containers
  • the frontend must live inside an existing Kubernetes/ECS environment
  • you need custom Nginx behavior
  • you need runtime-generated configuration
  • networking requirements make the container platform convenient
  • deployment tooling is already heavily container-oriented
  • operational consistency matters more than infrastructure minimalism

The architecture might be:

                  Internet


                    ALB

          ┌──────────┴──────────┐
          │                     │
          ▼                     ▼
   Frontend Service       Backend Service
          │                     │
          ▼                     ▼
       Nginx                  Node.js


     React files

This is perfectly valid.

It's simply solving a different organizational or infrastructure problem.


Don't Choose Docker Just Because "Production Uses Docker"

This is an easy trap to fall into when learning DevOps.

We learn technologies such as:

Docker
Kubernetes
ECS
Nginx
Terraform

and naturally want to use them.

But architecture isn't about using the largest number of technologies.

Suppose the requirement is simply:

Host these static files securely and make them available globally.

Then:

S3
+
CloudFront

may solve the problem extremely well.

Adding:

Docker
+
ECR
+
ECS
+
ALB
+
Nginx

should ideally be justified by an actual requirement.


But Don't Choose S3 Just Because It Has Fewer Components Either

The opposite mistake is also possible.

Imagine an organization already operates hundreds of applications on Kubernetes.

They have standardized:

Git


CI


Docker Image


Registry


Kubernetes


Deployment

Their developers know this system.

Their monitoring understands it.

Their security tooling scans it.

Their rollback procedures use it.

Introducing a completely separate deployment system specifically for one frontend may introduce its own operational complexity.

So the smallest architecture diagram isn't always the simplest system for the organization operating it.

That's why architectural decisions require context.


The Question I Like to Ask

Instead of asking:

Should React be deployed using S3 or Docker?

Ask:

What runtime capabilities does this frontend actually require?

If the answer is:

Serve HTML
Serve JavaScript
Serve CSS
Serve images

then static hosting deserves serious consideration.

If the answer includes:

Custom web-server behavior
Platform-standard containers
Runtime configuration
Internal networking
Specific proxy behavior
Unified deployment model

then Nginx + Docker may be reasonable.

And if the answer includes:

Server-side rendering
Server components
Dynamic HTML generation
Backend-for-frontend logic

then we're entering a different category entirely.

We may actually need application compute.


A Simple Decision Model

You can think about frontend deployment like this:

              React Application


          Does it require server-side
              execution at runtime?
                 /          \
               Yes           No
                │             │
                ▼             ▼
        Application       Static files
          Runtime              │

                       Do we specifically
                       need containers?
                          /        \
                        Yes         No
                         │           │
                         ▼           ▼
                    Nginx +       Object
                    Docker        Storage
                         │           │
                         ▼           ▼
                    ECS / K8s    S3 + CDN

This isn't an absolute rule.

But it's a useful starting point.


The Bigger Lesson

The interesting part of this discussion isn't really S3 versus Nginx.

It's understanding the difference between:

build-time responsibilities

and:

runtime responsibilities

During build time, we may need:

Node.js
npm
Vite
TypeScript compiler
Bundler
Minifier

During runtime, a client-side React SPA may only need:

Something that serves files.

That "something" could be:

S3 + CloudFront

or:

Nginx

or another static hosting platform.

Once you separate build-time requirements from runtime requirements, frontend deployment architecture becomes much easier to understand.


Final Takeaway

A React application does not need Docker simply because it is going to production.

And it doesn't need S3 simply because it produces static files.

The correct architecture depends on what you're trying to achieve.

For a straightforward client-side SPA, an architecture such as:

Git


CI/CD

 │ npm run build

S3


CloudFront


Users

is simple, scalable, and well aligned with the nature of the application.

A containerized architecture:

Git


CI/CD

 │ docker build

Container Registry


ECS / Kubernetes


Nginx


Users

can also be perfectly reasonable when containers fit the organization's deployment and operational model.

The important thing is understanding why each component exists.

Don't start with:

"Which technology should I use?"

Start with:

"What problem does my application actually need the infrastructure to solve?"

Once that question is clear, choosing between S3 + CloudFront, Nginx + Docker, or a true server-side runtime becomes much easier.


What's Next?

We've now established that a frontend build often produces static files, and that those files can be served either through object storage/CDN infrastructure or through a traditional web server such as Nginx.

But there's another question that naturally follows:

What actually happens when someone types https://myapp.com into their browser?

How does the request travel through:

Browser


DNS


CloudFront / Load Balancer


Frontend


Backend API

And where do HTTPS, TLS certificates, DNS records, caching, CORS, reverse proxies, and API routing fit into the picture?

That's where frontend deployment starts connecting with the broader world of networking and cloud infrastructure.

Continue Reading

Read Monitoring with a Purpose: Building the Ultimate Prometheus & Grafana Dashboard
#DevOps

Monitoring with a Purpose: Building the Ultimate Prometheus & Grafana Dashboard

In a modern DevOps architecture, "it's working" isn't an answer—it's a temporary state. As an Architect, I’ve learned that the difference between a 2 AM emergency and a peaceful night's sleep is the quality of your observability stack. Today, we’re diving into the "Gold Standard" of monitoring: **Prometheus** and **Grafana**.

Read post ->
Read Mastering grep: A DevOps Field Guide to Pattern Matching
#DevOps

Mastering grep: A DevOps Field Guide to Pattern Matching

In the world of infrastructure management and log diving, `grep` (Global Regular Expression Print) is the ultimate multi-tool. While most people use it for simple string matching, its true power lies in the advanced flags that allow you to filter through thousands of lines of log data with surgical precision.

Read post ->
Read Why Do We Build React Apps for Production Instead of Running npm run dev?
#Frontend

Why Do We Build React Apps for Production Instead of Running npm run dev?

Have you ever wondered why we run npm run build before deploying a React application instead of simply running npm run dev on the production server? Let's understand what actually happens behind the scenes.

Read post ->