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.cominto 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.