How to Configure Nginx to Serve Static Files

How to Configure Nginx to Serve Static Files

Serving static files is the most fundamental thing Nginx does, and it’s also where I see the most avoidable misconfigurations — wrong root paths, broken try_files logic, permission errors, or missing MIME types that make a browser download a file instead of rendering it. In this guide, I’m going to walk through setting up Nginx to serve static files properly, from a bare HTML site to a more complex setup with multiple directories, custom MIME types, and directory browsing where appropriate.

Why This Matters

Even in a world of dynamic apps and API backends, static file serving underpins almost everything: the HTML shell of a single-page app, images, downloadable files, favicon, robots.txt, sitemap.xml, and often entire documentation or marketing sites. Nginx is exceptionally good at this — it can serve static files with very low memory overhead and extremely high throughput, which is exactly why it’s used as the front door for so many architectures, even ones that are mostly dynamic underneath.

Getting the static file configuration right also lays the foundation for caching, compression, and security headers, all of which build on the same root and location logic.

Prerequisites

  • Nginx installed and running.
  • A directory containing the static files you want to serve.
  • Sudo access to edit configuration files and reload Nginx.

Confirm Nginx is active:

sudo systemctl status nginx

Understanding root vs alias

This trips people up constantly, so I want to get it out of the way early.

root appends the request URI to the specified path.

location /images/ {
    root /var/www/example.com;
}

A request for /images/logo.png resolves to /var/www/example.com/images/logo.png.

alias replaces the matched location prefix with the specified path.

location /images/ {
    alias /var/www/example.com/media/;
}

A request for /images/logo.png resolves to /var/www/example.com/media/logo.png — note that images/ is dropped entirely and replaced by media/.

Mixing these up is probably the single most common static file bug I’ve had to debug for other people. If your location path and your actual directory structure don’t match 1:1, you need alias, not root.

Step-by-Step Configuration

Step 1: Create Your Directory Structure

For this walkthrough, I’ll assume a simple structure:

sudo mkdir -p /var/www/example.com/public
sudo chown -R www-data:www-data /var/www/example.com

Place an index.html there to test with:

echo "<h1>It works</h1>" | sudo tee /var/www/example.com/public/index.html

Step 2: Create the Server Block

sudo nano /etc/nginx/sites-available/example.com
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /var/www/example.com/public;
    index index.html index.htm;

    location / {
        try_files $uri $uri/ =404;
    }
}

Step 3: Enable the Site

sudo ln -s /etc/nginx/sites-available/example.com /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx

Step 4: Understand try_files

This directive is doing more work than it looks like. try_files $uri $uri/ =404; tells Nginx:

  1. Try to serve the exact URI as a file ($uri).
  2. If that fails, try it as a directory ($uri/), which looks for an index file inside.
  3. If both fail, return a 404.

For a single-page app that handles routing client-side, I change the fallback:

location / {
    try_files $uri $uri/ /index.html;
}

This means any URL that doesn’t match a real file (like /dashboard/settings) still gets served index.html, letting JavaScript-based routing take over.

Step 5: Serve Files From a Separate Directory

If you have static assets living outside your document root — say, user uploads stored at /data/uploads:

location /uploads/ {
    alias /data/uploads/;
    autoindex off;
}

Step 6: Handle MIME Types Correctly

Nginx uses /etc/nginx/mime.types to map file extensions to Content-Type headers. This file covers the vast majority of common types, but if you’re serving something unusual (like .webmanifest or .wasm), you may need to add it manually:

http {
    include mime.types;
    types {
        application/manifest+json webmanifest;
        application/wasm wasm;
    }
    default_type application/octet-stream;
}

Without correct MIME types, browsers can misinterpret files — a common symptom is a browser prompting a download instead of rendering an SVG or a font failing to load with a console warning about MIME type mismatch.

A Complete Example Configuration

Here’s a fuller example combining a marketing site, an uploads directory, and sensible defaults:

server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;

    root /var/www/example.com/public;
    index index.html;

    # Main site
    location / {
        try_files $uri $uri/ =404;
    }

    # User uploads served from a different path on disk
    location /uploads/ {
        alias /data/uploads/;
        autoindex off;
        add_header X-Content-Type-Options "nosniff";
    }

    # Favicon and robots without cluttering logs
    location = /favicon.ico { log_not_found off; access_log off; }
    location = /robots.txt  { log_not_found off; access_log off; }

    # Deny access to hidden files
    location ~ /\. {
        deny all;
        access_log off;
        log_not_found off;
    }

    # Custom 404 page
    error_page 404 /404.html;
    location = /404.html {
        internal;
    }
}

Enabling Directory Listing (When You Actually Want It)

Sometimes — a file repository, an internal tools directory — you do want Nginx to show a browsable file listing instead of requiring an index.html.

location /downloads/ {
    alias /var/www/example.com/downloads/;
    autoindex on;
    autoindex_exact_size off;
    autoindex_localtime on;
}
  • autoindex_exact_size off shows human-readable sizes (1.2K, 3.4M) instead of exact byte counts.
  • autoindex_localtime on shows file timestamps in the server’s local time rather than UTC.

I only enable this deliberately, and I always pair it with access restrictions (see the security section below) — an open directory listing on a production domain is an easy way to accidentally expose files you didn’t mean to.

Testing Your Configuration

Basic reachability test:

curl -I http://example.com/

Look for HTTP/1.1 200 OK and confirm Content-Type: text/html.

Test a static asset’s MIME type:

curl -I http://example.com/style.css

Should show Content-Type: text/css.

Test 404 behavior:

curl -I http://example.com/does-not-exist

Should return HTTP/1.1 404 Not Found, and if you configured a custom error page, the body should reflect that.

Test alias vs root resolution. If files aren’t showing up as expected, log the resolved path temporarily:

location /uploads/ {
    alias /data/uploads/;
    add_header X-Debug-Path $request_filename;
}

Then check the response header to see exactly what filesystem path Nginx tried to serve.

Troubleshooting Common Issues

403 Forbidden on every request. Almost always a permissions issue. Nginx’s worker process (usually running as www-data or nginx) needs read access to the files and execute (traversal) access to every parent directory:

sudo chmod -R o+rX /var/www/example.com

Or better, set proper ownership:

sudo chown -R www-data:www-data /var/www/example.com

404 errors for files that clearly exist. This is almost always a root/alias mismatch, or a try_files fallback that’s too aggressive. Double check the resolved path with the X-Debug-Path trick above.

SELinux blocking access (on RHEL/CentOS systems). Even with correct Linux permissions, SELinux can silently block Nginx from reading files outside its expected context:

sudo semanage fcontext -a -t httpd_sys_content_t "/var/www/example.com(/.*)?"
sudo restorecon -Rv /var/www/example.com

Trailing slash inconsistencies with alias. If your location block doesn’t end in / but your alias path does (or vice versa), you’ll get unpredictable path resolution. Keep them symmetric — both with trailing slashes, as shown throughout this guide.

Files serving with the wrong Content-Type. Confirm include mime.types; is present in your http block (it is by default in most installs, but custom configs sometimes strip it out), and check that the file extension is actually recognized.

Security Considerations

  • Never serve your entire web root with directory listing enabled unless it’s intentional and access-controlled.
  • Block access to dotfiles (.env, .git, .htaccess remnants) with the regex block shown in the complete example above — this alone has saved more than one client from exposing credentials.
  • Restrict uploads directories from executing code. If users can upload files to a directory Nginx serves, make sure that directory can never be interpreted as a script:
location /uploads/ {
    alias /data/uploads/;
    location ~ \.php$ {
        deny all;
    }
}
  • Set X-Content-Type-Options: nosniff globally to prevent browsers from guessing content types in ways that could be exploited.
  • Limit request methods on static content — there’s rarely a reason a static file location needs to accept POST or DELETE:
if ($request_method !~ ^(GET|HEAD)$) {
    return 405;
}

(I use if sparingly in Nginx since it can behave unexpectedly in some contexts, but for a narrow method check inside a static location block, it’s safe and commonly used.)

Performance Tips

  • Enable sendfile so the kernel handles file transfer directly instead of copying data through userspace:
sendfile on;
tcp_nopush on;
  • Combine with caching headers (see the caching guide) — serving static files fast is only half the win; letting browsers skip the request entirely is the other half.
  • Use open_file_cache to avoid repeated filesystem lookups for frequently requested files.
  • Keep static and dynamic content on separate location blocks so you can tune buffering, timeouts, and caching independently for each.
  • Consider a CDN for high-traffic static assets — Nginx handles the origin extremely well, but geographic distribution is something only a CDN edge network can really solve.

Real-World Use Cases

  • A documentation site built with a static site generator (Hugo, Jekyll, MkDocs) — pure static file serving, no backend at all, just root and try_files.
  • A single-page application where try_files $uri $uri/ /index.html; lets client-side routing handle all the “pages” while Nginx only ever serves one real file.
  • A shared file drop for an internal team, using autoindex on behind basic auth so people could grab exported reports without needing an app built around it.
  • Serving user avatar uploads from a directory outside the web root using alias, keeping uploaded content physically separate from the application code for easier backup and permission management.

Best Practices Summary

  • Understand root vs alias before writing any location block that touches a non-standard directory structure.
  • Use try_files deliberately — know exactly what fallback behavior you want.
  • Keep permissions tight: readable by the Nginx worker, not world-writable.
  • Block dotfiles and anything that shouldn’t be publicly reachable.
  • Only enable directory listing when you actually mean to.
  • Pair static file serving with proper MIME types, caching headers, and compression for a complete setup.

Static file serving looks simple on the surface, and honestly, most of the time it is — but the handful of edge cases around root/alias, permissions, and try_files logic are exactly where I’ve seen the most confusing, hard-to-diagnose bugs. Get the fundamentals right here, and everything else you build on top of Nginx becomes a lot more predictable.

Serving Multiple Sites From One Server

Once you’re comfortable with a single static site, the next thing that usually comes up is serving several sites from the same Nginx instance. I do this with separate server blocks, one per domain, each with its own root:

server {
    listen 80;
    server_name site-a.com;
    root /var/www/site-a.com/public;
    location / { try_files $uri $uri/ =404; }
}

server {
    listen 80;
    server_name site-b.com;
    root /var/www/site-b.com/public;
    location / { try_files $uri $uri/ =404; }
}

Nginx uses the Host header (matched against server_name) to decide which server block handles an incoming request, a mechanism called name-based virtual hosting. This is why server_name needs to be accurate and unique across your configuration — two server blocks with overlapping server_name values and no other distinguishing factor will behave unpredictably, with Nginx picking based on internal matching rules rather than anything obvious from the config file’s order.

I keep each site in its own file under /etc/nginx/sites-available/, symlinked into sites-enabled/, so I can enable or disable individual sites without touching the others — sudo rm /etc/nginx/sites-enabled/site-a.com disables it instantly without deleting the underlying configuration.

Handling Large File Downloads

Static file serving isn’t just about small assets — I’ve configured Nginx plenty of times to serve large downloadable files (software releases, video files, backups). A few settings matter more here than for typical web assets:

location /downloads/ {
    alias /data/downloads/;
    sendfile on;
    tcp_nopush on;
    aio threads;
    output_buffers 2 512k;
}

aio threads; offloads file reads to a thread pool rather than blocking a worker process, which matters a lot when serving large files — without it, a single slow disk read for one large download can stall other requests being handled by the same worker. I also often add limit_rate for large public downloads to prevent a handful of downloads from saturating the server’s total bandwidth:

location /downloads/ {
    alias /data/downloads/;
    limit_rate_after 10m;
    limit_rate 2m;
}

This lets the first 10MB transfer at full speed, then caps subsequent throughput at 2MB/s per connection — a reasonable balance between user experience and protecting overall server bandwidth for concurrent downloaders.

Frequently Asked Questions

Can Nginx serve files it doesn’t have direct filesystem access to, like from cloud storage? Not directly as static files — for S3 or similar object storage, you’d typically either proxy requests through to the storage service’s HTTP endpoint (using proxy_pass) or, more commonly, point a CDN directly at the bucket and skip Nginx for that content entirely.

What’s the fastest way to serve thousands of small files, like a build output with many chunks? The static file configuration in this guide already handles that well — Nginx’s static file serving is highly efficient at scale. The bigger win at that point tends to be combining it with the caching and compression techniques covered in the other guides, rather than anything specific to file count.

Why does my site work with /page but not /page/, or vice versa? This comes down to try_files and how your content is structured — a request for /page/ looks for an index file inside a page directory, while /page looks for a literal file or falls through your try_files list differently. If your static site generator produces /page/index.html, make sure both URL forms resolve as your users expect, or add a redirect to normalize one to the other.

Is it bad practice to serve everything from one giant location / block? Not inherently, but splitting static assets into their own regex-matched blocks (as shown in the caching guide) gives you the ability to apply different caching, logging, and security rules to different content types — which is usually worth the small amount of extra configuration once a site grows past a handful of pages.

Serving Files With Correct Character Encoding

One issue I’ve run into more than once, especially with older content migrated from a different system: static HTML or text files serving with the wrong character encoding, causing garbled text for non-ASCII characters. Nginx lets you set this explicitly:

charset utf-8;
source_charset utf-8;

Placed in the server or http block, this ensures the Content-Type header includes the correct charset (text/html; charset=utf-8), which browsers use to decide how to interpret byte sequences as text. Without it, browsers fall back to guessing or use a default that may not match your actual file encoding, leading to the classic “mojibake” symptom of question marks or garbled characters replacing accented letters, non-Latin scripts, or special punctuation.

Restricting Access to Specific File Extensions

Sometimes I want to serve a directory generally but block a specific subset of file types within it — a common example is a document-sharing folder where I want PDFs and images served, but any accidentally-uploaded source file (.sql, .env, .bak) blocked outright:

location /shared/ {
    alias /data/shared/;

    location ~* \.(sql|bak|env|log|conf)$ {
        deny all;
    }
}

Nested location blocks like this let you carve out exceptions within a broader static-serving rule without needing to restructure the whole directory layout. I use this pattern fairly often on shared upload directories where I can’t fully control what ends up there, as a defense-in-depth measure against accidental exposure of files that were never meant to be public.

A Note on Symlinks

By default, Nginx will follow symbolic links when serving static files, which is usually what you want (a current symlink pointing at the latest deployed release directory, for example, is a very common deployment pattern). If you specifically need to disable this — for a multi-tenant environment where a rogue symlink pointing outside the intended directory would be a security concern — that’s controlled with disable_symlinks:

location /uploads/ {
    alias /data/uploads/;
    disable_symlinks on;
}

This adds a filesystem check on every request to confirm the resolved path doesn’t cross a symlink boundary, which carries a small performance cost, so I only enable it in environments where untrusted users can influence what ends up in a served directory — not as a blanket default for typical static file serving.

Total
1
Shares

Leave a Reply

Previous Post
How to Create Custom Error Pages in Nginx

How to Create Custom Error Pages in Nginx

Next Post
How to Set Up Nginx as a Load Balancer

How to Set Up Nginx as a Load Balancer

Related Posts