Serving Static Files #
Serving static files is one thing Nginx does exceptionally well — in fact, it’s one of the main reasons Nginx was created. But to do it right in a production environment, there are many layers you need to understand: how Nginx determines which file path to read, how file transfer is optimized at the kernel level, how browsers are directed to cache, and how you make sure sensitive files aren’t exposed.
This article dissects each layer in depth.
How Nginx Determines the File Path #
When a request comes in, Nginx needs to answer one question: “Which file on the filesystem should I send?” The answer is determined by the root directive and the request URI.
The Basic Formula #
File path = root value + full request URI
This sounds simple, but the implications matter. Let’s look at it concretely:
server {
listen 80;
server_name example.com;
root /var/www/html;
location / {
try_files $uri $uri/ =404;
}
}
With the configuration above:
| Request | Calculation | File read |
|---|---|---|
GET / | /var/www/html + / | /var/www/html/index.html (via index) |
GET /about.html | /var/www/html + /about.html | /var/www/html/about.html |
GET /images/logo.png | /var/www/html + /images/logo.png | /var/www/html/images/logo.png |
GET /css/style.css | /var/www/html + /css/style.css | /var/www/html/css/style.css |
Critical point: the entire URI, including the first segment, is appended to the root value. This differs from alias, which we’ll cover in the Root & Alias article.
The Request-to-File Flow #
flowchart TD
A["Browser sends request\nGET /images/logo.png HTTP/1.1\nHost: example.com"] --> B["Nginx receives the request\nin a worker process"]
B --> C{"Find a server block\nmatching the\nHost header"}
C --> D["Match: server_name example.com\nroot /var/www/html"]
D --> E{"Find a location block\nmatching the\nURI /images/logo.png"}
E --> F["Match: location /\ntry_files active"]
F --> G["Calculate the file path\n/var/www/html + /images/logo.png\n= /var/www/html/images/logo.png"]
G --> H{"Does the file\nexist on the filesystem?"}
H -- Yes --> I["Read the file from disk\nsend it to the browser\nHTTP 200 OK"]
H -- No --> J["try_files: try the next one\nor return 404"]Root at the Server vs Location Level #
root can be placed in the http, server, or location context. Values from deeper contexts override the ones above them (they don’t add). Best practice is to put root at the server level, then override in specific location blocks if needed:
server {
listen 80;
server_name example.com;
# Default root for the whole site
root /var/www/example.com;
location / {
# Inherits the root from the server block: /var/www/example.com
try_files $uri $uri/ =404;
}
location /docs/ {
# Special root override for /docs/
# GET /docs/guide.html → /var/www/documentation/guide.html
root /var/www/documentation;
# CAUTION: /docs/ STILL gets added to the path!
# That means the file must exist at /var/www/documentation/docs/guide.html
# If the file lives at /var/www/documentation/guide.html, use alias!
try_files $uri =404;
}
location /static/ {
# If files live at /var/www/assets/ (not /var/www/assets/static/)
# use alias, not root
alias /var/www/assets/;
try_files $uri =404;
}
}
This is the trap that most often confuses developers. When you write root /var/www/documentation inside location /docs/, Nginx still looks for files at /var/www/documentation/docs/ — not /var/www/documentation/.
The try_files Directive: The Smart Way to Serve Files #
try_files is the most important directive for serving static files. It tries the listed locations in order and uses the first one found:
location / {
# Order: try the URI as a file → try as a directory → return 404
try_files $uri $uri/ =404;
}
Let’s break down what happens for GET /blog/post-satu:
$uri— try/var/www/html/blog/post-satuas a direct file$uri/— try/var/www/html/blog/post-satu/as a directory (then look forindex.htmlinside it)=404— if both fail, return 404
try_files Patterns for Various Cases #
server {
root /var/www/app;
# ─── Regular static site ───────────────────────────────────
location / {
try_files $uri $uri/ =404;
}
# ─── SPA (React, Vue, Angular) ───────────────────────────
# All paths without files are redirected to index.html
# So the client-side router can handle routing
location / {
try_files $uri $uri/ /index.html;
}
# ─── PHP with index.php as the front controller ────────
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# ─── Download files with a fallback to an info page ────────
location /downloads/ {
try_files $uri =404;
# No need to check $uri/ because we don't want directory listings
}
}
Why try_files Is Better Than if #
There’s a temptation to write conditions like this:
# ANTI-PATTERN: using if to check file existence
location / {
if (!-e $request_filename) {
return 404;
}
}
try_files is better because:
- Atomic — the entire check happens in one operation
- Safer — no race condition edge cases
- More efficient — optimized internally by Nginx
- Clearer — the fallback order is explicit and easy to read
File Transfer Optimization: sendfile, tcp_nopush, tcp_nodelay #
This is the trio of directives that work at the kernel level to maximize static file transfer efficiency.
sendfile: Zero-Copy Transfer #
Without sendfile, file transfer goes through a long path:
Disk → Kernel buffer → User space (Nginx) → Kernel buffer → Network socket
With sendfile on, Nginx uses the sendfile() system call, which lets the kernel transfer data directly from a file descriptor to a socket without copying it into user space:
Disk → Kernel buffer → Network socket (direct, without passing through user space)
The result: much lower CPU usage, especially for large files and high traffic.
http {
# Enable zero-copy transfer via sendfile()
# Highly recommended for all static file serving
sendfile on;
# Limit the size per sendfile() call
# Prevents a single large-file request from blocking a worker process too long
# 0 = no limit (default) — better set to 1m for production
sendfile_max_chunk 1m;
}
tcp_nopush: Packet Batching #
tcp_nopush on enables the TCP_CORK option on the socket. Nginx holds data transmission until the buffer is full, then sends everything at once in a single batch of TCP packets:
http {
sendfile on;
# Enable TCP_CORK — buffer packets until full before sending
# Reduces the number of TCP packets (more network-efficient)
# Only effective when sendfile is on
tcp_nopush on;
}
The benefit: fewer TCP packets sent, which means less TCP/IP header overhead and fewer kernel context switches.
tcp_nodelay: Send Small Data Immediately #
tcp_nodelay on disables Nagle’s algorithm — which usually holds back small data transmissions to bundle them with other data. For keep-alive connections actively sending small data (like API responses or small HTML), this prevents unnecessary latency:
http {
sendfile on;
tcp_nopush on;
# Send data immediately without waiting for the buffer to fill
# Useful for keep-alive connections after sendfile finishes
tcp_nodelay on;
}
Complete Transfer Configuration #
http {
# Optimal trio for serving static files
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on;
tcp_nodelay on;
# Keep-alive connection — reduce repeated TCP handshake overhead
keepalive_timeout 65;
keepalive_requests 1000;
}
MIME Types: Telling the Browser How to Read Files #
Browsers need to know how to handle the files they receive. This information is conveyed through the Content-Type header, which Nginx determines based on the file extension and the mime.types file.
How MIME Types Work in Nginx #
http {
# Load the extension → MIME type mapping from Nginx's built-in file
include /etc/nginx/mime.types;
# Fallback if the extension is unrecognized
# application/octet-stream = binary download
default_type application/octet-stream;
}
The /etc/nginx/mime.types file contains hundreds of mappings, for example:
text/html html htm shtml;
text/css css;
text/javascript js;
application/json json;
image/png png;
image/jpeg jpeg jpg;
image/svg+xml svg svgz;
font/woff woff;
font/woff2 woff2;
application/wasm wasm;
Adding or Overriding a MIME Type #
server {
# WebAssembly: make sure the MIME type is correct so browsers can run it
location ~* \.wasm$ {
add_header Content-Type application/wasm;
}
# Manifest file for PWAs
location = /manifest.json {
add_header Content-Type application/manifest+json;
expires 1d;
}
# Source maps for debugging (don't expose publicly in production!)
location ~* \.map$ {
# Restrict to internal IPs only
allow 10.0.0.0/8;
allow 192.168.0.0/16;
deny all;
add_header Content-Type application/json;
}
}
Cache Headers: Optimize Loading Speed #
Static files like CSS, JavaScript, images, and fonts rarely change. By setting the right cache headers, you can dramatically reduce requests to your server — returning visitors get content straight from their browser cache.
Cache Busting Strategy #
The most effective way is to embed a content hash in the file name. Modern build tools (Webpack, Vite, Parcel) do this automatically:
style.css → style.a1b2c3d4.css
app.js → app.e5f6g7h8.js
logo.png → logo.i9j0k1l2.png
Because the file name always changes when the file content changes, you can tell browsers to store these files forever — you’re confident that a different file name means different content.
Cache Configuration by File Type #
server {
root /var/www/html;
# ─── HTML: don't cache or cache very briefly ─────────
# HTML is the entry point — changes must be visible immediately
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
}
# ─── CSS and JavaScript with hashes (cache busting) ───────
# File names already contain a hash: style.abc123.css
# Safe to cache forever because the file name ALWAYS changes if content changes
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# ─── Images and fonts ──────────────────────────────────────
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
log_not_found off;
}
# ─── Files that may change (JSON, XML, manifest) ──────
location ~* \.(json|xml)$ {
expires 1h;
add_header Cache-Control "public, max-age=3600";
}
}
Understanding immutable
#
immutable in Cache-Control is a special instruction: “This file will never change during the cache period. The browser doesn’t need to send a conditional request (If-Modified-Since or If-None-Match) to verify — just use it from the cache directly.”
Without immutable, browsers usually still send a request to the server to verify file freshness, even if the file is already in cache. With immutable, browsers truly skip this step — faster.
Without immutable:
Browser → Server: GET /style.abc123.css (If-Modified-Since: ...)
Server → Browser: 304 Not Modified (still a round trip!)
With immutable:
Browser: File is in cache and not expired → use it directly, no request to server
Gzip Compression: Reduce Transfer Size #
Gzip compression significantly reduces the size of text files before they’re sent to the browser. HTML, CSS, and JavaScript files can usually be compressed by 60-80% of their original size.
http {
# Enable gzip
gzip on;
# Compression level: 1 (fast, small compression) – 9 (slow, big compression)
# Levels 4-6 are the sweet spot between speed and size
gzip_comp_level 6;
# Only compress files above a certain size
# Small files (<1KB) aren't worth compressing (overhead outweighs the benefit)
gzip_min_length 1024;
# Add the Vary: Accept-Encoding header
# Important for CDNs — ensures the CDN caches gzip and non-gzip versions separately
gzip_vary on;
# Also compress for proxy requests (not just direct requests)
gzip_proxied any;
# File types to compress
gzip_types
text/plain
text/css
text/html
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml
font/ttf
font/otf
application/font-woff
application/font-woff2;
}
Brotli: Better Compression Than Gzip #
If your server supports the Brotli module (available in Nginx Plus or via a third-party module), Brotli produces 15-25% better compression than gzip for text files:
# Note: requires the ngx_brotli module
http {
# Use Brotli for static files (pre-compressed)
brotli_static on;
# Enable on-the-fly Brotli compression
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/javascript;
}
Security: Block Access to Sensitive Files #
Careless static file serving can expose files that shouldn’t be public. This is the security configuration that must be present in every production setup:
server {
root /var/www/html;
# ─── Block hidden files (starting with a dot) ──────────────
# .env, .git, .htaccess, .DS_Store, etc.
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
# ─── Block configuration and backup files ───────────────────
location ~* \.(env|git|svn|bak|backup|sql|sh|py|rb|pl|conf|ini|log)$ {
deny all;
log_not_found off;
}
# ─── Block PHP files accidentally left in the public dir ─
# (useful if the public dir and app dir aren't separated)
location ~* \.php$ {
deny all;
}
# ─── Block access to the uploads directory (direct files only) ─
location /uploads/ {
# Allow access to image files only
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
expires 7d;
}
# Block all other files in /uploads/
deny all;
}
}
Why .env Files Are Often Exposed #
The most fatal mistake is putting the entire repository in the server’s document root. A structure like this is very dangerous:
/var/www/
└── myapp/ ← document root
├── .env ← EXPOSED! contains DB password, API key
├── .git/ ← EXPOSED! the entire source code can be downloaded
├── config.php ← EXPOSED!
└── public/ ← this should be the document root
└── index.html
The correct structure:
server {
# Point the root to the public/ subdirectory
root /var/www/myapp/public;
# .env, config.php, and .git are in /var/www/myapp/ — can never be accessed
# because our root is /var/www/myapp/public/
}
Complete Production Configuration: Static Website #
Here’s the configuration summarizing everything discussed above — ready for a production static website, including the build output of React, Vue, Nuxt, Next.js, Hugo, or any other static framework:
# /etc/nginx/conf.d/example.com.conf
server {
listen 80;
server_name example.com www.example.com;
# ─── Redirect to HTTPS ────────────────────────────────────
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
root /var/www/example.com;
index index.html;
# ─── SSL ──────────────────────────────────────────────────
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
# ─── Transfer Optimization ────────────────────────────────────
sendfile on;
sendfile_max_chunk 1m;
tcp_nopush on;
tcp_nodelay on;
# ─── Gzip ─────────────────────────────────────────────────
gzip on;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_vary on;
gzip_proxied any;
gzip_types
text/plain text/css text/html text/javascript
application/javascript application/json
image/svg+xml font/woff2;
# ─── Security Headers ─────────────────────────────────────
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin" always;
server_tokens off;
# ─── SPA Routing ──────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# ─── CSS and JS with hashes (immutable cache) ─────────────
location ~* \.(css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# ─── Images and fonts ──────────────────────────────────────
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|avif|woff|woff2|ttf)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
log_not_found off;
}
# ─── Files that change frequently ─────────────────────────────
location = /manifest.json {
expires 1d;
add_header Cache-Control "public, max-age=86400";
}
location = /service-worker.js {
expires -1;
add_header Cache-Control "no-cache";
}
# ─── Security: block sensitive files ───────────────────────
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* \.(env|git|svn|bak|backup|sql|sh|conf|ini|log)$ {
deny all;
log_not_found off;
}
}
Verification and Debugging #
Check Response Headers #
# Check Content-Type and Cache-Control
curl -I https://example.com/style.a1b2c3.css
# Expected output:
# HTTP/2 200
# content-type: text/css
# cache-control: public, immutable
# expires: [date 1 year ahead]
# Check whether gzip is active
curl -H "Accept-Encoding: gzip" -I https://example.com/app.js
# Should show: content-encoding: gzip
# Check whether sensitive files are really blocked
curl -I https://example.com/.env
# Should be: HTTP/2 403 or 404
# Check whether path traversal is blocked
curl -I https://example.com/../etc/passwd
# Should be: HTTP/2 400 Bad Request (Nginx handles this automatically)
Check the Error Log #
# See the latest errors if files can't be served
tail -f /var/log/nginx/example.com-error.log
# Common errors:
# [error] ... open() "/var/www/html/favicon.ico" failed (2: No such file)
# → The file doesn't exist at the expected path
#
# [error] ... "/var/www/html/" is forbidden
# → No index file, autoindex off → 403
#
# [error] ... failed (13: Permission denied)
# → The Nginx worker doesn't have permission to read the file
Check File Permissions #
# Make sure the Nginx worker can read the files
ls -la /var/www/example.com/
# Directories must be executable (x) by the nginx user
# Files must be readable (r) by the nginx user
# How to check the Nginx user
ps aux | grep nginx | grep worker
# Usually: www-data (Ubuntu/Debian) or nginx (CentOS/RHEL)
# Set the correct permissions
sudo find /var/www/example.com -type d -exec chmod 755 {} \;
sudo find /var/www/example.com -type f -exec chmod 644 {} \;
sudo chown -R www-data:www-data /var/www/example.com
Summary #
- File path =
root+ full URI — Nginx concatenates them directly, including the URI’s first segment. Usealiasif the directory name differs from the URL.try_files $uri $uri/ =404is the standard pattern for static sites; replace=404with/index.htmlfor SPAs.sendfile on+tcp_nopush onenable zero-copy kernel-level transfer — must be on for the best static file performance.- Aggressive caching (
expires 1y; immutable) for CSS/JS/images that use hashes in their file names (cache busting).- Gzip compression can reduce transfer size by 60-80% for text files — enable it in the
httpcontext.- Block hidden files (
/\.) and sensitive files (.env,.git,.sql) explicitly in production configuration.- The document root must point to the
public/subdirectory — never make the repository root the document root.