# Mastering Virtual Files in Nginx: A Complete Guide

> One post from jmrp.io, published as its own document. Index: https://jmrp.io/llms-full.txt

> Generated: 2026-08-31

URL: https://jmrp.io/blog/002-serve-virtual-files-nginx/
Language: en
Alternate: https://jmrp.io/es/blog/002-serve-virtual-files-nginx/index.md
License: https://creativecommons.org/licenses/by/4.0/
Type: TechArticle
Published: 2025-12-18
Updated: 2026-08-02
Last verified: 2026-08-22 · nginx 1.31.3
Author: José Manuel Requena Plens
Summary: Serve virtual files in Nginx without disk I/O. Covers root vs alias vs try_files, named locations, Kubernetes health endpoints, and containers.
Tags: Nginx, DevOps, Security
Topics: Nginx (Q306144), Reverse proxy (Q1192309), Kubernetes (Q22661306), Docker (Q15206305), robots.txt (Q80776), security.txt (Q65057155)

Questions answered:

**How do I serve robots.txt or a health check in Nginx without a file on disk?**

Use a location block with the return directive to send inline content, for example `location = /robots.txt { default_type text/plain; return 200 "User-agent: *\nAllow: /\n"; }`. This serves the content directly from the config without any physical file, which is ideal for containers and managed platforms where you can't modify the image or filesystem.

**What is the difference between root and alias in Nginx?**

`root` appends the request URI to the given path, so `root /var/www` with a request for `/images/photo.jpg` serves `/var/www/images/photo.jpg`. `alias` replaces the matched location prefix with the path, so `alias /data/photos/` for `location /images/` serves `/data/photos/photo.jpg`. Use `root` for standard document roots and `alias` for files in non-standard locations.

**Why is my Nginx location block not matching the right request?**

Nginx selects locations by priority, not config file order. Exact match (`=`) wins first, then prefix with `^~`, then regex (`~` and `~*`), then plain prefix by longest match. Use `=` for virtual files like `/robots.txt` so the exact match wins and Nginx stops searching immediately, which is also the fastest option.

**How do I set the Content-Type for an inline return response in Nginx?**

Use the `default_type` directive inside the location block, for example `default_type application/json;` before a JSON `return 200`. The `add_header` directive can add other headers but only applies to certain response codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308) unless the `always` flag is used.

**Should I use njs or the return directive for virtual files in Nginx?**

Use `return` for static content because it is faster; use njs (Nginx JavaScript) only when you need logic such as conditional content or dynamic JSON. Unlike `return`, which serves static strings, njs allows full programmatic control.

**How do I add Kubernetes liveness and readiness probes with Nginx?**

Define exact-match location blocks that return a simple response, such as `location = /healthz { access_log off; return 200 'OK'; }` for liveness and `location = /ready { access_log off; return 200 'Ready'; }` for readiness. Disabling access_log keeps the logs from being spammed by frequent probes.


Steps (Serve virtual files in Nginx without disk I/O):
1. Understand location matching priority
2. Choose root, alias, or try_files
3. Serve inline content with the return directive
4. Add named locations and internal fallbacks
5. Define standard web and .well-known files
6. Use njs for dynamic virtual files
7. Add health and monitoring endpoints
8. Validate and reload the configuration

---

When deploying modern web applications—especially containerized ones or services on managed platforms—you often don't have direct access to the web root. What happens when you need to serve `robots.txt`, `security.txt`, or a health check endpoint? You can't just drop a file into a Docker image.

**Nginx is more than a reverse proxy.** It's a powerful tool for generating and serving content that doesn't physically exist on disk. This technique is essential for:

- **Containerized apps** where you can't modify the image
- **Managed platforms** without filesystem access
- **Microservices** requiring standardized endpoints
- **Security compliance** files that change independently of app versions

In this comprehensive guide, we'll explore Nginx's powerful directives for serving virtual files, from basic techniques to advanced patterns used in production Kubernetes deployments.

## TL;DR — serve files Nginx never reads from disk

- **Location matching wins by priority, not config order:** exact `=` first, then `^~`, then regex, then longest prefix — use `=` for virtual files like `/robots.txt`.
- **`root` vs `alias` vs `try_files`:** `root` appends the URI, `alias` replaces the matched prefix, `try_files` checks files in order with a fallback.
- **The `return` directive serves inline content** (text, JSON, redirects, empty `204`) without any physical file; set the type with `default_type`.
- **Named locations (`@name`) and `internal`** enable SPA fallbacks, backend proxying, and protected downloads (watch for path traversal with `$arg_file`).
- **Serve standard web and `.well-known` files** (robots.txt, humans.txt, favicon.ico, security.txt, ACME challenges) inline or via `alias`.
- **Use njs only for logic;** add exact-match Kubernetes health probes with `access_log off`, then validate with `nginx -t` and reload.

---

## How does Nginx choose which location block wins?

Before serving virtual files, you must understand how Nginx selects which `location` block handles a request. This is **the most important concept** for effective Nginx configuration.

### Location matching priority

Nginx evaluates location blocks in a specific order. The first match **by priority** wins, not the first in the config file:

**Location Block Priority (Highest to Lowest)**

| Priority | Modifier | Type | Example |
| --- | --- | --- | --- |
| 1 | `=` | Exact match | `location = /robots.txt` |
| 2 | `^~` | Prefix (stops regex search) | `location ^~ /static/` |
| 3 | `~` | Case-sensitive regex | `location ~ \.php$` |
| 4 | `~*` | Case-insensitive regex | `location ~* \.(jpg\|png)$` |
| 5 | (none) | Prefix (longest match) | `location /api/` |

### Practical example

Consider this configuration:

**File: `location-priority.conf`**

```nginx
server {
    # Priority 5: Prefix (fallback)
    location / {
        proxy_pass http://app:3000;
    }
    
    # Priority 1: Exact match - WINS for /robots.txt
    location = /robots.txt {
        return 200 "User-agent: *\nAllow: /\n";
    }
    
    # Priority 4: Case-insensitive regex
    location ~* \.(jpg|png|gif)$ {
        root /var/www/images;
    }
    
    # Priority 2: Prefix with ^~ - WINS for /static/*
    location ^~ /static/ {
        alias /var/www/static/;
    }
}
```

**Key point**

**Performance Tip:** Use `=` (exact match) for virtual files like `/robots.txt`. It's the fastest because Nginx stops searching immediately after a match.

---

## Core directives: root, alias, try_files

These three directives determine _where_ Nginx looks for files.

Understanding the difference is crucial.

### What's the difference between root and alias?

**root vs alias vs try_files**

| Directive | Behavior | Best For |
| --- | --- | --- |
| `root` | Appends URI to path | Standard document roots |
| `alias` | Replaces matched prefix with path | Serving files from non-standard locations |
| `try_files` | Checks files in order, falls back to last | SPAs, conditional file serving |

### Visual comparison

**Comparison: root (appends URI) vs alias (replaces prefix)**

**root (appends URI)**

```nginx
location /images/ {
    root /var/www;
}

# Request: /images/photo.jpg
# Serves:  /var/www/images/photo.jpg
#          ^^^^^^^^ root + URI
```

**alias (replaces prefix)**

```nginx
location /images/ {
    alias /data/photos/;
}

# Request: /images/photo.jpg
# Serves:  /data/photos/photo.jpg
#          ^^^^^^^^^^^^ alias replaces /images/
```

**Warning**

**Common Mistake:** For prefix directory aliases (e.g., `location /images/`), include a trailing slash on both the location and the alias path. For exact-match aliases (e.g., `location = /robots.txt`), trailing slashes are not applicable.

### try_files for fallback chains

The `try_files` directive is incredibly powerful for virtual files:

**try_files example**

```nginx
location / {
    # Check if physical file exists, then directory, then fallback
    try_files $uri $uri/ @backend;
}

# Named location for backend fallback
location @backend {
    proxy_pass http://app:3000;
}
```

The order of checking is:

1. `$uri` — Try the exact file path
2. `$uri/` — Try as a directory with index
3. `@backend` — Fall back to named location

---

## The return directive

For small, simple content, the `return` directive is more efficient than creating physical files.

### Syntax variations

**return Directive Patterns**

| Pattern | Description | Example |
| --- | --- | --- |
| `return CODE;` | Return status only | `return 204;` |
| `return CODE TEXT;` | Return status with body | `return 200 "OK";` |
| `return CODE URL;` | Redirect (301, 302, 307, 308) | `return 301 https://...;` |

### Inline content example

**return examples**

```nginx
server {
    # Simple text response
    location = /robots.txt {
        default_type text/plain;
        return 200 "User-agent: *\nDisallow: /private/\n";
    }
    
    # JSON response
    location = /api/status {
        default_type application/json;
        return 200 '{"status":"healthy","version":"1.0.0"}';
    }
    
    # Empty success (for pings)
    location = /ping {
        return 204;
    }
    
    # Redirect
    location = /old-page {
        return 301 /new-page;
    }
}
```

**Info**

**Note:** Use `default_type` to set Content-Type for inline `return` bodies. The `add_header` directive can add other headers but only applies to certain response codes (200, 201, 204, 206, 301, 302, 303, 304, 307, 308) unless the `always` flag is used.

---

## Named locations and fallbacks

Named locations (prefixed with `@`) enable sophisticated fallback patterns without URL rewriting.

### How does the @fallback pattern work?

**File: `named-location.conf`**

```nginx
server {
    root /var/www/html;
    
    location / {
        # Try static file first, then directory, then app
        try_files $uri $uri/ @app;
    }
    
    # Named location - can't be accessed directly
    location @app {
        proxy_pass http://backend:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
```

### SPA (Single Page Application) pattern

For React, Vue, Angular apps that use client-side routing:

**File: `spa-fallback.conf`**

```nginx
server {
    root /var/www/app;
    index index.html;
    
    location / {
        # Try file, then directory, then index.html for client routing
        try_files $uri $uri/ /index.html;
    }
    
    # Static assets (bypass try_files for performance)
    location ^~ /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}
```

### Internal locations

Use the `internal` directive to prevent direct access:

**File: `internal-location.conf`**

```nginx
location /protected-files/ {
    internal;  # Can only be reached via internal redirect
    alias /var/secure/files/;
}

# Option A: Direct internal redirect with secure link and error_page
location /download {
    internal;
    alias /var/secure/files;
    
    # Secure link check
    secure_link $arg_md5,$arg_expires;
    # IMPORTANT: Include $arg_file in hash to prevent path manipulation
    secure_link_md5 "$secure_link_expires$uri$arg_file$remote_addr secret";

    if ($secure_link = "") { return 403; }
    if ($secure_link = "0") { return 410; }

    # Forward to named location to serve file
    error_page 418 = @serve_file;
    return 418;
}

location @serve_file {
    internal;
    alias /var/secure/files/$arg_file;
}

# Option B: Backend sets X-Accel-Redirect header
location /download-via-backend {
    # Backend (e.g., Node.js/Python) validates auth and returns:
    # X-Accel-Redirect: /protected-files/myfile.pdf
    proxy_pass http://backend:3000;
}
```

**Warning**

**Security Warning:** The `alias /var/secure/files/$arg_file;` pattern is vulnerable to path traversal attacks if `$arg_file` is not validated. An attacker could request `?file=../../../etc/passwd` to access files outside the intended directory.

**Mitigations:**

- Ensure `secure_link_md5` includes `$arg_file` in the hash computation
- Validate filenames with a `map` directive (e.g., reject patterns containing `..`)
- Use a backend application to validate and generate `X-Accel-Redirect` headers

**Info**

**Note:** The `X-Accel-Redirect` header must be set by an upstream response (e.g., from a backend application), not via `add_header` in Nginx. Use `try_files` or `rewrite` for direct internal redirects within Nginx itself.

---

## Standard web files

These virtual files are fundamental for SEO, security, and compliance.

### robots.txt

The [robots.txt](https://developers.google.com/search/docs/crawling-indexing/robots/intro) file tells web crawlers which parts of your site to access.

**Option 1/2 — Inline (return)**

```nginx
location = /robots.txt {
    default_type text/plain;
    return 200 "User-agent: *\nAllow: /\nSitemap: https://example.com/sitemap.xml\n";
}
```

**Option 2/2 — External (alias)**

```nginx
location = /robots.txt {
    alias /etc/nginx/assets/robots.txt;
    default_type text/plain;
}
```

### humans.txt

Give credit to your team with [humans.txt](https://humanstxt.org/):

**File: `humans.txt` location**

```nginx
location = /humans.txt {
    default_type text/plain;
    # Note: Update the "Last update" date when deploying changes
    return 200 "/* TEAM */\nLead: Jane Doe\nContact: jane@example.com\n\n/* SITE */\nPowered by: Nginx, Astro\nLast update: 2025-12-18\n";
}
```

### ads.txt & app-ads.txt

For advertising transparency and fraud prevention. The [IAB Tech Lab Specification](https://iabtechlab.com/ads-txt/) requires a specific CSV format: `Domain, Publisher ID, Account Type, Certification Authority ID`.

**File: `ads.txt` location**

```nginx
# Desktop ads.txt
location = /ads.txt {
    default_type text/plain;
    return 200 "google.com, pub-0000000000000000, DIRECT, f08c47fec0942fa0\n";
}

# Mobile app-ads.txt
location = /app-ads.txt {
    alias /etc/nginx/assets/app-ads.txt;
    default_type text/plain;
}
```

### favicon.ico

While modern browsers prefer SVG or PNG, `favicon.ico` is still requested by legacy tools and crawlers. A single `.ico` file typically bundles 16x16, 32x32, and 48x48 sizes.

**favicon handling**

```nginx
# Serve from custom location
location = /favicon.ico {
    alias /etc/nginx/assets/favicon.ico;
    access_log off;
    expires 1y;
}

# Or return empty (no favicon)
location = /favicon.ico {
    return 204;
    access_log off;
}
```

> **Reference:** [How to Favicon: A comprehensive guide](https://evilmartians.com/chronicles/how-to-favicon-in-2021-six-files-that-fit-most-needs)

### Mobile and touch icons

Modern web applications need icons for mobile home screens and progressive web app (PWA) features.

#### Apple Touch Icon

iOS uses this when a website is added to the home screen. The standard is a **180x180 PNG** file without transparency.

#### Android & Web App Manifest

Android Chrome uses `site.webmanifest` to identify app icons (standard sizes are 192x192 and 512x512).

**File: `mobile-icons.conf`**

```nginx
# Apple Touch Icon
location = /apple-touch-icon.png {
    alias /var/www/assets/apple-touch-icon.png;
    access_log off;
    expires 1y;
}

# Web App Manifest
location = /site.webmanifest {
    default_type application/manifest+json;
    return 200 '{
        "name": "My App",
        "short_name": "App",
        "icons": [
            { "src": "/icon-192.png", "type": "image/png", "sizes": "192x192" },
            { "src": "/icon-512.png", "type": "image/png", "sizes": "512x512" }
        ],
        "theme_color": "#ffffff",
        "background_color": "#ffffff",
        "display": "standalone"
    }';
}
```

**Tip**

**Production Tip:** While inline JSON is convenient for examples, serving manifest files from the filesystem is more maintainable. Create a real `site.webmanifest` file and serve it with `alias` to avoid syntax errors and simplify updates.

---

## The .well-known directory

The [.well-known](https://www.iana.org/assignments/well-known-uris) directory is a standard location for site-wide metadata.

### security.txt (RFC 9116)

Allow security researchers to report vulnerabilities:

**File: `security.txt`**

```nginx
location = /.well-known/security.txt {
    default_type text/plain;
    return 200 "Contact: mailto:security@example.com\nExpires: 2027-12-31T23:59:59.000Z\nPreferred-Languages: en, es\nCanonical: https://example.com/.well-known/security.txt\n";
}

# Also serve at root for compatibility
location = /security.txt {
    return 301 /.well-known/security.txt;
}
```

### ACME Challenges (Let's Encrypt)

Handle certificate validation across multiple services:

**File: `acme-challenge.conf`**

```nginx
# ACME Challenges - Choose only ONE of the following two location blocks:

# Option A: Serve from shared directory (for certbot standalone/webroot mode)
location ^~ /.well-known/acme-challenge/ {
    root /var/www/certbot;
    default_type text/plain;
}

# Option B: Proxy to certbot container (for Docker setups - comment out Option A if using this)
# location ^~ /.well-known/acme-challenge/ {
#     proxy_pass http://certbot:80;
# }
```

### Mobile app associations

Link your website to mobile apps:

**File: `mobile-associations.conf`**

```nginx
# Android App Links (assetlinks.json)
location = /.well-known/assetlinks.json {
    alias /etc/nginx/assets/assetlinks.json;
    default_type application/json;
}

# iOS Universal Links (no file extension!)
location = /.well-known/apple-app-site-association {
    alias /etc/nginx/assets/apple-app-site-association;
    default_type application/json;
}
```

### Other .well-known files

**Common .well-known URIs**

| Path | Purpose | Content-Type |
| --- | --- | --- |
| `/.well-known/change-password` | Redirect to password change page | Redirect (302) |
| `/.well-known/webfinger` | User discovery (ActivityPub, email) | application/jrd+json |
| `/.well-known/openid-configuration` | OpenID Connect discovery | application/json |
| `/.well-known/matrix/client` | Matrix client discovery | application/json |

---

## Dynamic content with Nginx JavaScript (njs)

For complex scenarios, the standard for serving virtual files with logic (conditional content, dynamic JSON) is **njs** (Nginx JavaScript). Unlike `return`, which only serves static strings, `njs` allows full programmatic control.

### Setup

First, ensure the module is loaded in `nginx.conf`:

```nginx
load_module modules/ngx_http_js_module.so;
```

### Example: dynamic robots.txt

Serve different `robots.txt` rules based on the request (e.g., blocking AI bots dynamically):

**File: `/etc/nginx/njs/virtual.js`**

```javascript
function robots(r) {
    const ua = r.headersIn['User-Agent'] || "";
    
    // Block specific bots dynamically
    if (ua.includes("GPTBot") || ua.includes("CCBot")) {
        r.return(200, "User-agent: *\nDisallow: /\n");
    } else {
        r.return(200, "User-agent: *\nAllow: /\n");
    }
}

export default { robots };
```

**File: `njs-location.conf`**

```nginx
http {
    js_import /etc/nginx/njs/virtual.js;

    server {
        location = /robots.txt {
            js_content virtual.robots;
        }
    }
}
```

**Warning**

**Note:** Serving different `robots.txt` content based on User-Agent is shown here for illustrative purposes only. In practice, crawlers expect a consistent `robots.txt` and may not re-fetch it with different User-Agents. For real-world [bot blocking](/blog/005-implementing-tarpit-nginx/), consider using `robots.txt` with specific User-Agent rules, or server-side blocking based on IP/User-Agent patterns.

**Info**

**Performance Note:** `njs` is very fast, but `return` is faster. Use `return` for static content and `njs` only when you need logic.

---

## Health and monitoring endpoints

Critical for container orchestration and observability.

### Basic health check

**File: `health-endpoint.conf`**

```nginx
location = /health {
    access_log off;  # Don't spam logs
    default_type application/json;
    return 200 '{"status":"healthy"}';
}
```

### Kubernetes Probes

**File: `k8s-probes.conf`**

```nginx
# Liveness probe - is the process alive?
location = /healthz {
    access_log off;
    return 200 'OK';
}

# Readiness probe - is it ready to receive traffic?
location = /ready {
    access_log off;
    # Could check backend connectivity here
    return 200 'Ready';
}

# Startup probe - has initialization completed?
location = /startup {
    access_log off;
    return 200 'Started';
}
```

### stub_status monitoring

Expose Nginx internal metrics

(requires `ngx_http_stub_status_module`):

**File: `stub_status.conf`**

```nginx
location = /nginx_status {
    stub_status on;
    access_log off;
    
    # Restrict access to internal networks
    allow 127.0.0.1;
    allow 10.0.0.0/8;
    allow 172.16.0.0/12;
    allow 192.168.0.0/16;
    deny all;
}
```

**stub_status output**

```text
Active connections: 42
server accepts handled requests
 12345 12345 98765
Reading: 0 Writing: 3 Waiting: 39
```

**Tip**

**Modern Dashboard:** If you prefer a visual interface, **[Nginx-UI](https://nginxui.com/)** is a powerful open-source tool that leverages the `stub_status` module to provide real-time monitoring graphs, configuration editing, and SSL management in a clean web dashboard.

---

## Performance optimization

Maximize efficiency when serving virtual files.

### File serving optimization

**File: `performance.conf`**

```nginx
http {
    # Zero-copy file transfers
    sendfile on;
    
    # Optimize packet sending
    tcp_nopush on;
    tcp_nodelay on;
    
    # Keep connections alive
    keepalive_timeout 65;
    
    # Gzip compression
    gzip on;
    gzip_types text/plain text/css application/json application/javascript;
    gzip_min_length 1000;
}
```

### Caching headers

**File: `caching.conf`**

```nginx
# Static assets - cache aggressively
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# Virtual files - short cache or no cache
location = /robots.txt {
    expires 1d;
    add_header Cache-Control "public";
    return 200 "...";
}

# Health endpoints - never cache
location = /health {
    add_header Cache-Control "no-store" always;
    return 200 '{"status":"ok"}';
}
```

---

## Deployment patterns

Real-world configurations for modern infrastructure.

### Docker container pattern

**File: `docker-nginx.conf`**

```nginx
upstream app {
    server app:3000;
}

server {
    listen 80;
    
    # Virtual files served by Nginx
    location = /robots.txt {
        default_type text/plain;
        return 200 "User-agent: *\nAllow: /\n";
    }
    
    location = /health {
        access_log off;
        return 200 'OK';
    }
    
    # Static assets from volume
    location /static/ {
        alias /var/www/static/;
        expires 1y;
    }
    
    # Everything else to app container
    location / {
        proxy_pass http://app;
        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;
    }
}
```

### Kubernetes ingress pattern

**File: `k8s-ingress-nginx.conf`**

```nginx
server {
    listen 80;
    
    # Probes for K8s
    location = /healthz { return 200 'OK'; access_log off; }
    location = /ready { return 200 'Ready'; access_log off; }
    
    # Standard virtual files
    location = /robots.txt {
        default_type text/plain;
        return 200 "User-agent: *\nDisallow: /api/internal/\n";
    }
    
    # ACME for cert-manager (^~ prevents regex override)
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    
    # Service routing
    location /api/ {
        proxy_pass http://api-service:8080;
    }
    
    location / {
        proxy_pass http://frontend-service:3000;
    }
}
```

---

## Complete example configuration

A production-ready configuration combining all techniques:

**File: `/etc/nginx/sites-available/example.com.conf`**

```nginx
# Rate limiting zone (MUST be placed in http { } context, not inside server { } or location { })
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    
    # SSL configuration
    ssl_certificate /etc/ssl/certs/example.com.crt;
    ssl_certificate_key /etc/ssl/private/example.com.key;
    
    # Performance
    sendfile on;
    tcp_nopush on;
    
    # =========================================
    # VIRTUAL FILES
    # =========================================
    
    # SEO / Crawlers
    location = /robots.txt {
        default_type text/plain;
        return 200 "User-agent: *\nAllow: /\nDisallow: /api/internal/\nSitemap: https://example.com/sitemap.xml\n";
    }
    
    # Security
    location = /.well-known/security.txt {
        default_type text/plain;
        return 200 "Contact: mailto:security@example.com\nExpires: 2027-12-31T23:59:59.000Z\n";
    }
    
    # Health & Monitoring
    # Note: Nginx variables like $date_gmt are NOT interpolated in return body.
    # For dynamic values, use add_header or a backend.
    location = /health {
        access_log off;
        default_type application/json;
        add_header X-Timestamp $date_gmt always;
        return 200 '{"status":"healthy"}';
    }
    
    location = /nginx_status {
        stub_status on;
        access_log off;
        allow 127.0.0.1;
        deny all;
    }
    
    # ACME Challenges
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    
    # Mobile Apps
    location = /.well-known/apple-app-site-association {
        alias /etc/nginx/assets/apple-app-site-association;
        default_type application/json;
    }
    
    location = /.well-known/assetlinks.json {
        alias /etc/nginx/assets/assetlinks.json;
        default_type application/json;
    }
    
    # Favicon
    location = /favicon.ico {
        alias /var/www/static/favicon.ico;
        access_log off;
        expires 1y;
    }
    
    # =========================================
    # STATIC ASSETS
    # =========================================
    
    location ^~ /static/ {
        alias /var/www/static/;
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
    
    # =========================================
    # APPLICATION
    # =========================================
    
    location / {
        limit_req zone=general burst=20 nodelay;
        
        proxy_pass http://app:3000;
        proxy_http_version 1.1;
        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;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

# HTTP to HTTPS redirect
server {
    listen 80;
    server_name example.com;
    
    # Allow ACME on HTTP
    location ^~ /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }
    
    location / {
        return 301 https://$server_name$request_uri;
    }
}
```

---

## Troubleshooting

### Testing virtual files

```bash
# Test exact match
curl -v https://example.com/robots.txt

# Check response headers
curl -I https://example.com/health

# Follow redirects
curl -L https://example.com/old-page
```

### Validate configuration

```bash
# Syntax check
sudo nginx -t

# Reload configuration
sudo nginx -s reload

# Check error logs
tail -f /var/log/nginx/error.log
```

**Common Issues**

| Issue | Cause | Solution |
| --- | --- | --- |
| 404 for virtual file | Location not matching | Check location modifier (`=` vs prefix) |
| Wrong content served | Another location matching first | Review priority; use `=` for exact |
| Wrong Content-Type | Missing type header | Add `default_type` or `add_header` |
| alias path issues | Missing trailing slash | Ensure both location and alias have trailing `/` |

---

## Beyond static files: scripting modules

Sometimes you need more than just static strings. Here is how the main scripting options compare for 2025:

**Nginx Scripting Options Comparison**

| Language | Performance | Use Case | Recommendation |
| --- | --- | --- | --- |
| **njs (JavaScript)** | High | Dynamic content, header manipulation, complex routing. | **Recommended** for most users. Native integration. |
| **Lua (OpenResty)** | Very High (JIT) | High-load API gateways, complex business logic, caching. | Best for heavy-duty apps. Requires OpenResty build. |
| **Perl** | Medium | Legacy integration, text processing. | **Avoid** unless maintaining legacy systems. |

---

## How I run this on my own infrastructure

This site retired fifteen blog tags in one pass, which left sixty URLs that had
been indexed and linked pointing at pages that no longer existed. Every one of
them is an exact-match `location` block in the nginx config in front of this
page — the technique this article describes, applied to a real problem rather
than an illustrative one:

```nginx
location = /blog/tags/mtls/  { return 301 /blog/tags/security/; }
location = /blog/tags/mtls   { return 301 /blog/tags/security/; }
```

All sixty blocks are live right now — fifteen tags, each written twice for the
with- and without-trailing-slash forms, in both locales. They are `=` matches on
purpose: a prefix match on `/blog/tags/` would swallow the tags that still
exist, and the `=` form also stops nginx searching any further, which is the
priority rule from the top of this article doing real work.

One of them cost me an afternoon. The retired tag `key derivation` contains a
space, so the URL is `/blog/tags/key%20derivation/`. The obvious block does not
work:

```nginx
# Never matches. nginx decodes the URI *before* comparing it to a
# `location =` value, so by the time matching happens the path holds a
# literal space and this pattern holds "%20".
location = /blog/tags/key%20derivation/ { return 301 /blog/tags/cryptography/; }
```

The URI is normalized and percent-decoded before location matching, so the
value in the config has to be what the path decodes *to* — a literal space,
which then needs quoting for nginx to parse the directive at all:

```nginx
location = "/blog/tags/key derivation/" { return 301 /blog/tags/cryptography/; }
location = "/blog/tags/key derivation"  { return 301 /blog/tags/cryptography/; }
```

That is the version running now, and
`curl -I "https://jmrp.io/blog/tags/key%20derivation/"` returns the 301. Worth
knowing before you write an exact-match block for any path that can contain an
encoded character, because the failure is silent: nginx accepts the config,
`nginx -t` passes, and the rule simply never fires.

---

## Deep Dive

### Official Documentation

- [Nginx Beginner's Guide](https://nginx.org/en/docs/beginners_guide.html)
- [ngx_http_core_module](https://nginx.org/en/docs/http/ngx_http_core_module.html) — `location`, `root`, `alias`
- [ngx_http_rewrite_module](https://nginx.org/en/docs/http/ngx_http_rewrite_module.html) — `return`, `rewrite`

### Tutorials

- [DigitalOcean: Understanding Server and Location Blocks](https://www.digitalocean.com/community/tutorials/understanding-nginx-server-and-location-block-selection-algorithms)
- [Nginx Admin Guide: Web Server](https://docs.nginx.com/nginx/admin-guide/web-server/web-server/)

### Standards

- [RFC 9116: security.txt](https://datatracker.ietf.org/doc/html/rfc9116)
- [IANA Well-Known URIs](https://www.iana.org/assignments/well-known-uris)
- [robots.txt specification](https://developers.google.com/search/docs/crawling-indexing/robots/intro)

