Implement Cache with Nginx
Introduction
Nginxis 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 Asynchronousarchitecture. Instead of creating a new process/thread for each request, Nginx uses a small number of worker processes to manage all connections.
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 withETag) 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=3600caches for 1 hour).s-maxage=<seconds>: Similar tomax-age, but applies only toShared Cache (CDN, Proxy)and overridesmax-ageforCDNs.max-stale[=<seconds>]: Allows the Client to accept a stale response within a specified time frame.min-fresh=<seconds>: Requires theServerto 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, theBrowsermust revalidate with the Server before use. If the Server is disconnected (offline), theBrowsermust not use stale cache and must return an error (504).proxy-revalidate: Similar tomust-revalidate, but applies only toShared Cache (CDN/Proxy).immutable: Tells theBrowserthat this resource will never change during itsmax-age. TheBrowserwill never send revalidation requests even when the user hitsF5 (Reload). Commonly used for files with attachedhashes(likeapp.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 (5xxor network failure), the Browser is allowed to continue using stale data.
Summary Table of Common Practical Configurations
| Use Case | Recommended 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-13aor33a64df551425fcc55e4d42a148795d9 - Meaning: Two resources with identical
Strong ETagsmatch100%byte-for-byte. If even a space or line break changes, theETagchanges immediately.
- Form:
Weak ETag(Semantic precision):- Form:
W/"60c72b2f-13a(Starts withW/). - Meaning: Content may differ slightly technically (such as whitespaces, date formats) but the rendered display content is completely identical.
- Form:
Depending on your architecture, ETags can be generated by:
- Nginx generation: When
Nginxdirectly serves Static Files (images, JS, CSS, static HTML files),Nginxautomatically calculates the ETag based on 2 parameters: last modification time (Last-Modified) + file size (Content-Length). Application Servergeneration (NodeJS, Python, etc. for Dynamic APIs): For responses returning dynamic data (such asJSONfrom anAPI), the Backend calculates theETagitself (usuallyMD5orSHA-1hashing of the fullJSONpayload) and attaches it to theResponse Headerbefore 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:
Navigationlike 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-Controlbut includes the fieldIf-None-Match: "{ETag value}".
Normal reload (F5): The Browser automatically sendsCache-Control: max-age=0andIf-None-Match: "{ETag value}"to force data revalidation.Hard reload (Ctrl + F5): The Browser automatically sendsCache-Control: no-cachewithoutIf-None-Matchto 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
Etagis sent, fresh data is returned to the browser because there is no comparison factor to detect whether data onNginx/Serverhas changed. - If an
Etagis sent:- If the sent
Etagdiffers from the existingEtagorCache-Control: no-cacheis present, fresh data is returned to the browser. - If the sent
Etagequals the existingEtag, status code304 Not Modifiedis 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-Controlis sent, check whether data onNginx/Serverhas expired:- If expired, fetch fresh data for response (if handled by
Nginx, it forwards the request to theServer). - If not expired, check whether
Etagchanged:- If unchanged, return
304 Not Modified. - If changed, fetch fresh data for response.
- If unchanged, return
- If expired, fetch fresh data for response (if handled by
- If
Cache-Control: max-age=0:- When handled by a
CDNlikeNginx, it will always forward the request to theServer. - If the
Serverhandles it, it checks theEtagto return either304 Not Modifiedor fresh data.
- When handled by a
- If the sent
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
@Headerdecorator to configureCache-Controleffortlessly, or you can useres.setHeaderorres.setfor identical functionality. - When using
Controllerand returning values other thanStreamableFile, it automatically generates anEtagand responds to the client by default. If the browser sends one, it attachesEtagtoHeader If-None-Match, which can be inspected usingreq.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"]
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_servervariables represent backend services matching container names defined indocker-compose. - For
js|css|png|jpg|jpeg|gif|ico|svg|webp|woff2files usingCache-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|jsonfiles usingCache-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
immutablein that triggeringPage 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 /usingCache-Control "no-cache"and forwarding tofrontend_serverhandlingindex.html:- Although named no-cache, file storage in cache is permitted.
- However, freshness lifetime is zero, requiring browsers to send conditional requests (carrying
ETagorLast-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 OKwith new content: Browser updates cache and serves the new file.
- If Server returns
- For
location /api/v1/public/usingCache-Control "public, max-age=60, stale-while-revalidate=300"forwarding tobackend_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.
- During the initial 60 seconds (max-age=60):
- For
location /api/v1/private/usingCache-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.
Nginx compares the Cache-Control and Etag information in the Response Header against the If-None-Match field in the Request Header.
For the index.html file, Cache-Control: s-maxage=31536000 no-cache specifies caching only on the CDN and not within the browser.
When calling APIs, caching works in the same manner.
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).
You can observe that the response body contains empty data, resulting in extremely fast response times.
If you call the API directly to the Server using the same Etag value, you will get a 304 Not Modified result.
The Cache-Control header information is a combination of the API Response and the Nginx configuration.
This shows when the Cache-Control headers from both the Server and Nginx match.
Happy coding!
Comments
Post a Comment