all blog posts

Hardening React Deployments: Security-First Frontend Delivery with Nginx and Docker

·6 min read
ReactDockerNginxSecurityCSPDevOps

Frontend security is frequently treated as an afterthought. Teams run a linter, install an npm dependency scanner, and assume their single-page application (SPA) is safe. But in production, security vulnerabilities like Cross-Site Scripting (XSS), clickjacking, and MIME-sniffing exploits occur at the delivery and execution boundary.

In this guide, we walk through a production-hardened React delivery pipeline where security is enforced at the infrastructure layer using multi-stage Docker builds and an Alpine-based Nginx reverse proxy.


The Common Pitfalls in Frontend Deployments

Before designing a hardened setup, let’s examine why traditional SPA deployments fail in production:

  1. Shipping Node.js to Production
    Many developers accidentally deploy their entire development runtime (Node.js, npm, devDependencies, shell utilities) into the production container, ballooning image sizes from 25MB to 800MB+ and vastly expanding the attack surface.

  2. Inconsistent or Missing Security Headers
    Relying on client-side meta tags for security is brittle. If a page fails to hydrate or crashes, the browser may ignore the policy. Headers must be issued synchronously by the web server.

  3. Broken SPA Routing on Page Refresh
    React Router uses client-side history navigation. When a user directly visits /dashboard or refreshes a nested route, a standard web server returns a 404 Not Found unless configured with SPA fallbacks.

  4. Vulnerability to Clickjacking & Framing Attacks
    Without an explicit X-Frame-Options or frame-ancestors directive, malicious third-party websites can embed your app inside an invisible iframe to hijack user clicks.


Architecture: Multi-Stage Docker Pipeline

To achieve minimal attack surface and blazingly fast cold starts, we separate the build lifecycle from the runtime environment using Docker multi-stage builds.

┌──────────────────────────────────────┐
│       Stage 1: Build Image           │
│   node:20-alpine (~180MB)            │
│   • npm ci (clean install)           │
│   • npm run build -> /app/dist       │
└──────────────────┬───────────────────┘
                   │ Extract compiled static assets only

┌──────────────────────────────────────┐
│       Stage 2: Runtime Image         │
│   nginx:alpine-slim (~15MB)          │
│   • Zero Node.js or npm binaries     │
│   • Hardened nginx.conf              │
│   • Non-root process execution       │
└──────────────────────────────────────┘

The Optimized Dockerfile

Here is the production Dockerfile with minimal layers and security isolation:

# ==========================================================
# Stage 1: Build environment
# ==========================================================
FROM node:20-alpine AS builder

WORKDIR /app

# Cache package manifests for optimal layer caching
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit

# Copy source code and compile
COPY . .
RUN npm run build

# ==========================================================
# Stage 2: Minimal hardened runtime
# ==========================================================
FROM nginx:alpine-slim

# Remove default boilerplate configuration
RUN rm -rf /etc/nginx/conf.d/default.conf

# Copy production-ready Nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf

# Copy compiled static assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html

# Expose standard unprivileged HTTP port
EXPOSE 80

# Health check to ensure Nginx is actively answering requests
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:80/ || exit 1

CMD ["nginx", "-g", "daemon off;"]

Nginx as the Security Boundary

In this architecture, Nginx does not merely serve HTML and JS bundles—it acts as an enforcement gateway for strict security policies.

The Hardened nginx.conf

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    # --------------------------------------------------------
    # Core Security Headers
    # --------------------------------------------------------
    
    # Content Security Policy (CSP)
    add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.anshu.uk; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;

    # Prevent MIME-type sniffing
    add_header X-Content-Type-Options "nosniff" always;

    # Prevent iframe embedding (anti-clickjacking)
    add_header X-Frame-Options "DENY" always;

    # Referrer policy to prevent sensitive path leakage
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # Permissions policy (disable unwanted device sensors)
    add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;

    # --------------------------------------------------------
    # SPA Routing & Static Caching
    # --------------------------------------------------------

    # Handle React Router dynamic paths
    location / {
        try_files $uri $uri/ /index.html;
        add_header Cache-Control "no-cache, no-store, must-revalidate";
    }

    # Aggressive caching for hashed immutable assets
    location ~* \.(?:css|js|woff2?|png|jpg|jpeg|gif|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        access_log off;
    }

    # Deny access to hidden dotfiles (.git, .env)
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }
}

Dissecting the Content Security Policy

A Content Security Policy (CSP) instructs the browser which domains and script execution models are authorized. Let’s break down the rules:

Directive Configuration Purpose
default-src 'self' Fallback restriction: only load resources originating from the same host.
script-src 'self' Disallows arbitrary eval(), external injection, and untrusted remote script hosts.
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com Permits local stylesheets and Google Fonts stylesheets.
font-src 'self' https://fonts.gstatic.com Authorizes web fonts served from Google Fonts CDN.
connect-src 'self' https://api.anshu.uk Limits fetch() and XMLHttpRequest calls exclusively to authorized backend APIs.
frame-ancestors 'none' Blocks the app from being embedded inside any <iframe>, preventing UI redressing.

[!TIP] If you have third-party analytics or payment gateways (like Stripe or Razorpay), add their script and connect domains explicitly to script-src and connect-src instead of reverting to 'unsafe-eval'.


Verifying Your Deployment

Once deployed, verify that the security headers are properly attached using curl:

curl -I https://your-domain.com

Expected output:

HTTP/2 200
server: nginx
content-type: text/html; charset=UTF-8
x-content-type-options: nosniff
x-frame-options: DENY
referrer-policy: strict-origin-when-cross-origin
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
content-security-policy: default-src 'self'; script-src 'self'; ...
cache-control: no-cache, no-store, must-revalidate

You can also run your endpoint through securityheaders.com to target an A+ rating.


Summary Checklist

  • Multi-stage build removes Node.js and build tooling from production container.
  • Unused default Nginx configs removed.
  • try_files configured for single-page routing without 404s.
  • CSP header blocks unauthorized script origins and clickjacking.
  • Static hashed JS/CSS cached with immutable for 1 year.
  • Health checks configured for container orchestrators (Kubernetes / Docker Swarm).

By enforcing security at the infrastructure layer, you ensure that even if client-side code has edge-case bugs, the browser sandbox remains impenetrable.