Implement Cache with Nginx

Introduction

  • Nginx is an open-source web server that functions as a reverse proxy, load balancer and HTTP cache.
  • Nginx is famous for its ability to handle thousands of concurrent connections with very low memory consumption compared to servers like Apache.
  • The secret lies in its Event-driven and Asynchronous architecture. Instead of creating a new process/thread for each request, Nginx uses a small number of worker processes to manage all connections.
alt text

Roles

  • Web Server: Serves static content (HTML, CSS, images) extremely fast.
  • Reverse Proxy: Stands in front of backend servers (like NodeJS, Python, Java) to receive requests from clients, forward requests to the backend and return results to the client. This helps conceal system architecture and enhance security.
  • Load Balancer: Distributes traffic evenly across multiple backend servers, preventing overload on a single server and ensuring high availability.
  • HTTP Cache: Caches frequently requested content, reducing load on backend servers and accelerating response times for users.

Nginx is an essential tool in modern web architecture, especially for systems requiring high performance and scalability. In this article, we will use NestJS, NextJS and Nginx to implement system mechanisms for handling data caching, but first let us cover the key concepts including:

Cache-Control

The Cache-Control Header in the HTTP protocol defines caching rules for both Browser/Client and intermediate Servers (such as CDNs, Proxies). Cache-Control directives are divided into 4 main groups:

Cache Location Directives (Cache Ability)

Specifies who is allowed to store cached data.

  • public: All layers are allowed to cache data (Browsers, CDNs, Reverse Proxies, ISP Proxies). Commonly used for public static assets like images, CSS/JS files.
  • private: Only the user's Browser is allowed to cache. Intermediate proxies or CDNs cannot store it. Commonly used for personal data (e.g., account details, orders).
  • no-store: Completely bans caching. Every request must be sent directly to the Origin Server to fetch fresh data. Commonly used for sensitive data (bank information, OTPs).
  • no-cache: Data can still be stored in cache, but must send a request to the Server to revalidate whether data has changed (usually combined with ETag) before usage.

Expiration and Freshness Directives

Specifies how long cache remains valid before being considered stale.

  • max-age=<seconds>: Maximum time (in seconds) data is considered fresh in the Browser (e.g., max-age=3600 caches for 1 hour).
  • s-maxage=<seconds>: Similar to max-age, but applies only to Shared Cache (CDN, Proxy) and overrides max-age for CDNs.
  • max-stale[=<seconds>]: Allows the Client to accept a stale response within a specified time frame.
  • min-fresh=<seconds>: Requires the Server to return a response that remains fresh for at least the specified duration.

Revalidation Directives

Specifies handling rules when cache expires (becomes stale).

  • must-revalidate: When cache expires, the Browser must revalidate with the Server before use. If the Server is disconnected (offline), the Browser must not use stale cache and must return an error (504).
  • proxy-revalidate: Similar to must-revalidate, but applies only to Shared Cache (CDN/Proxy).
  • immutable: Tells the Browser that this resource will never change during its max-age. The Browser will never send revalidation requests even when the user hits F5 (Reload). Commonly used for files with attached hashes (like app.a8f9c1.js).

Stale Extension Directives

Optimizes user experience by serving stale data while waiting for fresh data in the background.

  • stale-while-revalidate=<seconds>: Immediately returns stale data to the User for instant UI rendering, while sending a background request to the Server to update cache for next time.
  • stale-if-error=<seconds>: If cache expires and the revalidation request to the Server fails (5xx or network failure), the Browser is allowed to continue using stale data.

Summary Table of Common Practical Configurations

Use CaseRecommended Cache-Control Configuration
Hashed Static Files (main.bf82a.js, logo.a1b2.png)public, max-age=31536000, immutable
Main HTML Pages / Dynamic APIs (Requires latest data)no-cache or max-age=0, must-revalidate
Sensitive Data (Payments, Tokens, Personal Information)no-store, private
UX Optimization / Moderate Content (News, Products)public, max-age=60, stale-while-revalidate=300

Etag

ETag is simply a string, consisting of 2 main forms:

  • Strong ETag (Absolute byte-for-byte precision):
    • Form: 60c72b2f-13a or 33a64df551425fcc55e4d42a148795d9
    • Meaning: Two resources with identical Strong ETags match 100% byte-for-byte. If even a space or line break changes, the ETag changes immediately.
  • Weak ETag (Semantic precision):
    • Form: W/"60c72b2f-13a (Starts with W/).
    • Meaning: Content may differ slightly technically (such as whitespaces, date formats) but the rendered display content is completely identical.

Depending on your architecture, ETags can be generated by:

  • Nginx generation: When Nginx directly serves Static Files (images, JS, CSS, static HTML files), Nginx automatically calculates the ETag based on 2 parameters: last modification time (Last-Modified) + file size (Content-Length).
  • Application Server generation (NodeJS, Python, etc. for Dynamic APIs): For responses returning dynamic data (such as JSON from an API), the Backend calculates the ETag itself (usually MD5 or SHA-1 hashing of the full JSON payload) and attaches it to the Response Header before returning.

How It Works

When the browser sends the initial request, it receives a response containing Cache-Control and Etag (if Nginx or Server is configured to return them). Afterwards, depending on user actions such as:

  • Navigation like entering the address bar, clicking a link or opening a link in a new tab:
    • If still within cache lifetime, it will use the pre-saved file from disk without requesting data.
    • If expired, the Browser sends a request where the header lacks Cache-Control but includes the field If-None-Match: "{ETag value}".
  • Normal reload (F5): The Browser automatically sends Cache-Control: max-age=0 and If-None-Match: "{ETag value}" to force data revalidation.
  • Hard reload (Ctrl + F5): The Browser automatically sends Cache-Control: no-cache without If-None-Match to bypass all caches.

Scenarios occurring based on Cache-Control and If-None-Match: "{ETag value}" headers sent to Nginx/Server are handled as follows:

  • If no Etag is sent, fresh data is returned to the browser because there is no comparison factor to detect whether data on Nginx/Server has changed.
  • If an Etag is sent:
    • If the sent Etag differs from the existing Etag or Cache-Control: no-cache is present, fresh data is returned to the browser.
    • If the sent Etag equals the existing Etag, status code 304 Not Modified is returned with an empty body (making the response payload significantly smaller than returning actual data and saving data parsing overhead), allowing the browser to reuse cached disk data.
    • If no Cache-Control is sent, check whether data on Nginx/Server has expired:
      • If expired, fetch fresh data for response (if handled by Nginx, it forwards the request to the Server).
      • If not expired, check whether Etag changed:
        • If unchanged, return 304 Not Modified.
        • If changed, fetch fresh data for response.
    • If Cache-Control: max-age=0:
      • When handled by a CDN like Nginx, it will always forward the request to the Server.
      • If the Server handles it, it checks the Etag to return either 304 Not Modified or fresh data.

Detail

NestJS

Create constant/file.ts to prepare the path to the target pdf file for download, adjust PUBLIC_DIR to fit your specific project structure:

import {join, resolve} from 'path'

export const PUBLIC_DIR = resolve(__dirname, 'public')

export const publicPath = (...path: string[]) => join(PUBLIC_DIR, ...path)

export const pdfFilePath = (fileName: string) =>
  publicPath('pdf', `${fileName}.pdf`)

Create file controller/cache.controller.ts:

import {
  Controller,
  Get,
  Header,
  NotFoundException,
  Param,
  Req,
  Res,
  StreamableFile,
} from '@nestjs/common'
import type {Request, Response} from 'express'
import {createReadStream, existsSync, statSync} from 'fs'
import {pdfFilePath} from 'src/constant'

@Controller('cache')
export class CacaheController {
  @Get('products')
  @Header('Cache-Control', 'public, max-age=60, stale-while-revalidate=300')
  getProducts() {
    const data = [
      {id: 1, name: 'Laptop', price: 1000},
      {id: 2, name: 'Phone', price: 500},
    ]

    return {data, updatedAt: new Date().toISOString()}
  }

  @Get('download/:fileName')
  @Header('Cache-Control', 'public, max-age=86400, must-revalidate')
  async download(@Param('fileName') fileName: string): Promise<StreamableFile> {
    const filePath = pdfFilePath(fileName)
    if (!existsSync(filePath)) {
      throw new NotFoundException('File not found')
    }
    const file = createReadStream(filePath)
    return new StreamableFile(file)
  }

  @Get('download-with-cache/:fileName')
  async downloadWithCache(
    @Param('fileName') fileName: string,
    @Req() req: Request,
    @Res({passthrough: true}) res: Response
  ): Promise<StreamableFile undefined |> {
    const filePath = pdfFilePath(fileName)

    if (!existsSync(filePath)) {
      throw new NotFoundException('File not found')
    }

    const stats = statSync(filePath)
    const etag = `W/"${stats.size.toString(16)}-${stats.mtime.getTime().toString(16)}"`

    if (req.headers['if-none-match'] === etag) {
      res.status(304)
      return
    }

    res.setHeader('Content-Type', 'application/pdf')
    res.set({
      'Content-Disposition': 'inline; filename="annual-report.pdf"',
      'Cache-Control': 'public, max-age=86400, must-revalidate',
      ETag: etag,
    })

    const file = createReadStream(filePath)
    return new StreamableFile(file)
  }

  @Get('user/profile')
  @Header('Cache-Control', 'no-store, private')
  getProfile() {
    return {
      id: 101,
      username: 'username',
      balance: 1000,
      updatedAt: new Date().toISOString(),
    }
  }

  @Get('current')
  getCurrentTime() {
    return {
      updatedAt: new Date().toISOString(),
    }
  }
}
  • You can see that NestJS supports the @Header decorator to configure Cache-Control effortlessly, or you can use res.setHeader or res.set for identical functionality.
  • When using Controller and returning values other than StreamableFile, it automatically generates an Etag and responds to the client by default. If the browser sends one, it attaches Etag to Header If-None-Match, which can be inspected using req.headers['if-none-match'].

Modify file main.ts

import {NestFactory} from '@nestjs/core'
import type {NestExpressApplication} from '@nestjs/platform-express'
import {join} from 'path'
import {AppModule} from 'src/app.module'

export async function bootstrap() {
  const app = await NestFactory.create<NestExpressApplication>(AppModule)
  app.useStaticAssets(join(process.cwd(), 'public'), {
    prefix: '/public',
  })
  const port = process.env.PORT
  await app.listen(port)
}

Create a Dockerfile to build the Docker image as follows:

FROM oven/bun:1-alpine AS builder

WORKDIR /app

COPY package.json bun.lockb* ./

RUN bun install --frozen-lockfile

COPY . .

RUN bun run build

RUN rm -rf node_modules && bun install --production --frozen-lockfile

FROM node:22-alpine AS runner

WORKDIR /app

ENV NODE_ENV=production
ENV NODE_PATH=/app/dist

USER node

COPY --chown=node:node --from=builder /app/node_modules ./node_modules
COPY --chown=node:node --from=builder /app/dist ./dist
COPY --chown=node:node --from=builder /app/package.json ./package.json

EXPOSE 5000

CMD ["node", "dist/src/main.js"]

NextJS

Create file cache/useCacheApi.ts to define API call hooks:

import {useMutation, useQuery} from '@tanstack/react-query'

const BASE_URL_PUBLIC = `/api/v1/public/cache`
const BASE_URL_PRIVATE = `/api/v1/private/cache`

const fetchData = async (endpoint: string, isPublic = true) => {
  const url = isPublic
    ? `${BASE_URL_PUBLIC}/${endpoint}`
    : `${BASE_URL_PRIVATE}/${endpoint}`
  const response = await fetch(url)
  if (!response.ok) {
    throw new Error(`HTTP error! Status: ${response.status}`)
  }
  return response.json()
}

const downloadFile = async (endpoint: string, defaultFileName: string) => {
  const response = await fetch(`${BASE_URL_PUBLIC}/${endpoint}`)
  if (!response.ok) {
    throw new Error('Could not load file')
  }
  const blob = await response.blob()
  const downloadUrl = window.URL.createObjectURL(blob)
  const link = document.createElement('a')
  link.href = downloadUrl
  link.download = endpoint.split('/').pop() || defaultFileName
  document.body.appendChild(link)
  link.click()
  link.remove()
  window.URL.revokeObjectURL(downloadUrl)
}

export const useCacheApis = () => {
  const productsQuery = useQuery({
    queryKey: ['products'],
    queryFn: () => fetchData('products'),
    enabled: false,
  })

  const downloadReportMutation = useMutation({
    mutationFn: () => downloadFile('download/report', 'report.pdf'),
  })

  const downloadCacheReportMutation = useMutation({
    mutationFn: () =>
      downloadFile('download-with-cache/report', 'report-with-cache.pdf'),
  })

  const userProfileQuery = useQuery({
    queryKey: ['userProfile'],
    queryFn: () => fetchData('user/profile'),
    enabled: false,
  })

  const currentCacheQuery = useQuery({
    queryKey: ['currentCache'],
    queryFn: () => fetchData('current', false),
    enabled: false,
  })

  return {
    productsQuery,
    downloadReportMutation,
    downloadCacheReportMutation,
    userProfileQuery,
    currentCacheQuery,
  }
}

Notice that /api/v1/public/cache, /api/v1/private/cache are used without declaring host origin because Nginx acts as an API Gateway forwarding requests directly to NextJS or NestJS respectively, requiring only same-origin API calls.

Create file cache/page.tsx:

'use client'

import {
  ClockCircleOutlined,
  DownloadOutlined,
  FileZipOutlined,
  ReloadOutlined,
  ShoppingOutlined,
  UserOutlined,
} from '@ant-design/icons'
import {Button, Card, notification} from 'antd'
import React, {useEffect} from 'react'
import {useCacheApis} from './useCacheApi'

const ApiButtons: React.FC = () => {
  const [api, contextHolder] = notification.useNotification()

  const {
    productsQuery,
    downloadReportMutation,
    downloadCacheReportMutation,
    userProfileQuery,
    currentCacheQuery,
  } = useCacheApis()

  const handleReload = () => {
    window.location.reload()
  }

  const showSuccessModal = (title: string, data: any) => {
    api.info({
      message: title,
      description: (
        <pre className="max-h-40 overflow-auto bg-gray-100 p-2 rounded text-xs mt-2">
          {JSON.stringify(data, null, 2)}
        </pre>
      ),
    })
  }

  useEffect(() => {
    if (productsQuery.data)
      showSuccessModal('Products Data', productsQuery.data)
  }, [productsQuery.data])

  useEffect(() => {
    if (userProfileQuery.data)
      showSuccessModal('User Profile', userProfileQuery.data)
  }, [userProfileQuery.data])

  useEffect(() => {
    if (currentCacheQuery.data)
      showSuccessModal('Current Cache', currentCacheQuery.data)
  }, [currentCacheQuery.data])

  return (
    <div className="flex justify-center items-center min-h-screen bg-slate-50 p-6">
      {contextHolder}
      <Card className="w-full max-w-2xl shadow-md rounded-xl" title="Query with Cache API">
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <Button icon="{<ReloadOutlined" type="default"/>}
            onClick={handleReload}
            className="h-12 text-sm font-medium border-gray-300 hover:border-blue-500"
            block
          >
            1. Reload Page
          </Button>

          <Button icon="{<ShoppingOutlined" type="primary"/>}
            loading={productsQuery.isFetching}
            onClick={() => productsQuery.refetch()}
            className="h-12 text-sm font-medium bg-blue-600 hover:bg-blue-500"
            block
          >
            2. Get Products
          </Button>

          <Button icon="{<DownloadOutlined" type="dashed"/>}
            loading={downloadReportMutation.isPending}
            onClick={() => downloadReportMutation.mutate()}
            className="h-12 text-sm font-medium text-emerald-600 border-emerald-500 hover:text-emerald-500 hover:border-emerald-400"
            block
          >
            3. Download Report
          </Button>

          <Button icon="{<FileZipOutlined" type="dashed"/>}
            loading={downloadCacheReportMutation.isPending}
            onClick={() => downloadCacheReportMutation.mutate()}
            className="h-12 text-sm font-medium text-teal-600 border-teal-500 hover:text-teal-500 hover:border-teal-400"
            block
          >
            4. Download Report (Cache)
          </Button>

          <Button ghost icon="{<UserOutlined" type="primary"/>}
            loading={userProfileQuery.isFetching}
            onClick={() => userProfileQuery.refetch()}
            className="h-12 text-sm font-medium"
            block
          >
            5. User Profile
          </Button>

          <Button icon="{<ClockCircleOutlined" type="default"/>}
            loading={currentCacheQuery.isFetching}
            onClick={() => currentCacheQuery.refetch()}
            className="h-12 text-sm font-medium text-purple-600 border-purple-300 hover:border-purple-500"
            block
          >
            6. Get Current
          </Button>
        </div>
      </Card>
    </div>
  )
}

export default ApiButtons 

Create Dockerfile

FROM oven/bun:1-alpine AS deps
WORKDIR /app

COPY package.json bun.lock ./

FROM oven/bun:1-alpine AS builder
WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

RUN bun run build

FROM node:22-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

COPY --from=builder /app/public ./public

COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static

USER nextjs
EXPOSE 3000

CMD ["node", "server.js"]
Nginx

Create file default.conf:

upstream frontend_server {
    server nextjs:3000;
}

upstream backend_server {
    server nestjs:5000;
}

server {
    listen 80;
    server_name localhost;

    etag on;
    gzip on;
    gzip_types text/plain text/css application/json application/javascript text/xml;

    location ~* \.([0-9a-f]+)\.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2?)$ {
        proxy_pass http://frontend_server;
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        access_log off;
    }

    location ~* \.(txt|xml|json)$ {
        proxy_pass http://frontend_server;
        expires 1d;
        add_header Cache-Control "public, max-age=86400, must-revalidate";
        access_log off;
    }

    location / {
        proxy_pass http://frontend_server;
        add_header Cache-Control "no-cache";

        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location /api/v1/public/ {
        proxy_pass http://backend_server/;
        add_header Cache-Control "public, max-age=60, stale-while-revalidate=300";
    }

    location /api/v1/private/ {
        proxy_pass http://backend_server/;
        add_header Cache-Control "no-store, private";
    }
}
  • Upstream frontend_server, backend_server variables represent backend services matching container names defined in docker-compose.
  • For js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2 files using Cache-Control "public, max-age=31536000, immutable":
    • If cache has not expired, browser reuses disk cache without making API calls.
    • If cache has expired, it queries API to verify changes, reusing disk cache if receiving 304 Not Modified.
    • Reloading the page (F5) does not trigger API calls.
    • If an API call fails (e.g., server 500 Error), cached disk files continue to serve.
  • For txt|xml|json files using Cache-Control "public, max-age=86400, must-revalidate":
    • If cache has not expired, browser reuses disk cache without making API calls.
    • If cache has expired, it queries API to verify changes, reusing disk cache upon receiving 304 Not Modified.
    • Diverges from immutable in that triggering Page reload (F5) forces an API call to revalidate.
    • If an API call fails, old cached files cannot be reused and return an error instead.
  • For location / using Cache-Control "no-cache" and forwarding to frontend_server handling index.html:
    • Although named no-cache, file storage in cache is permitted.
    • However, freshness lifetime is zero, requiring browsers to send conditional requests (carrying ETag or Last-Modified) to the Server every time a file is requested:
      • If Server returns 304 Not Modified: Browser uses cached file to save bandwidth.
      • If Server returns 200 OK with new content: Browser updates cache and serves the new file.
  • For location /api/v1/public/ using Cache-Control "public, max-age=60, stale-while-revalidate=300" forwarding to backend_server, stale data serves temporarily while silently fetching updates in the background.
    • During the initial 60 seconds (max-age=60):
      • Data is completely fresh.
      • Browser/CDN retrieves data directly from cache without hitting APIs.
    • From second 61 to second 360 (stale-while-revalidate=300 - lasting an additional 5 minutes):
      • Data is treated as stale.
      • Smart behavior: Browser immediately returns stale data to the UI (0ms latency), while triggering a background revalidation to the Server to update cache for subsequent reads.
    • After 6 minutes (after 360s): Cache expires completely, requiring the browser to wait for server responses before displaying data.
  • For location /api/v1/private/ using Cache-Control "no-store, private" applying maximum security rules prohibiting storage:
    • no-store
      • Instructs Browsers, CDNs and Nginx NOT TO STORE ANY DATA in Disk or Memory Cache.
      • Prevents creating temporary files on user devices, forcing API calls to the server on every request.
    • private
      • Specifies that response contains user-private information.
      • Forbids Intermediate Proxies (Cloudflare CDN, Nginx Caching, ISP Proxies) from caching, restricting processing exclusively to end-user devices. Paired with no-store, it reinforces security to ensure data never leaks into public caches.

Create docker-compose.yml to define all 3 services.

services:
  nextjs:
    build:
      context: .
    container_name: nextjs
    restart: always
    environment:
      - PORT=3000
      - HOSTNAME=0.0.0.0
    networks:
      - app_net

  nestjs:
    build:
      context: .
    container_name: nestjs
    restart: always
    environment:
      - PORT=5000
    networks:
      - app_net

  nginx:
    image: nginx:alpine
    container_name: nginx
    ports:
      - 8080:80
    volumes:
      - ./default.conf:/etc/nginx/conf.d/default.conf:ro
    depends_on:
      - nextjs
      - nestjs
    networks:
      - app_net

networks:
  app_net:
    driver: bridge

Start everything as follows

$ docker compose up -d
[+] up 4/4
 ✔ Network prod_app_net Created
 ✔ Container nestjs     Started
 ✔ Container nextjs     Started
 ✔ Container nginx      Started


As a result, you can see that static assets like css, ico,... files return Status Code: 304 Not Modified once cached.alt textalt text

Nginx compares the Cache-Control and Etag information in the Response Header against the If-None-Match field in the Request Header.alt text

For the index.html file, Cache-Control: s-maxage=31536000 no-cache specifies caching only on the CDN and not within the browser.alt text

When calling APIs, caching works in the same manner.alt textalt textalt text

When downloading a file, if it has not yet expired, the browser only sends a request to verify the file, and if there are no changes, it retrieves the file directly from cache (disk).alt textalt text

You can observe that the response body contains empty data, resulting in extremely fast response times.alt text

If you call the API directly to the Server using the same Etag value, you will get a 304 Not Modified result.alt text

The Cache-Control header information is a combination of the API Response and the Nginx configuration.alt text

This shows when the Cache-Control headers from both the Server and Nginx match.alt text

Happy coding!

See more articles here.

Comments

Popular posts from this blog

All Practice Series

Understanding React Server Component

Kubernetes Deployment for Zero Downtime

Sitemap

Deploying a NodeJS Server on Google Kubernetes Engine

React Practice Series

Docker Practice Series

Setting up Kubernetes Dashboard with Kind

Helm for beginer - Deploy nginx to Google Kubernetes Engine

DevOps Practice Series