Custom Error Page #

Nginx’s built-in error pages are very minimal — white text on a screen with a status code and the words “nginx/1.x.x”. For a production website, you almost certainly want to replace them: custom error pages that match your brand, provide useful information to users, and most importantly, don’t expose your server version to attackers.

This article covers error_page in depth: how it works, various configuration patterns, handling errors from backends, and production-ready implementation.

How error_page Works #

error_page defines what Nginx returns when a response has a certain status code. This directive performs an internal redirect — Nginx acts as if it received a new request for the specified URI, then serves it.

sequenceDiagram
    participant B as Browser
    participant N as Nginx
    participant F as Filesystem

    B->>N: GET /page-that-doesnt-exist HTTP/1.1
    N->>F: Look for file: /var/www/html/page-that-doesnt-exist
    F-->>N: Not found (ENOENT)
    Note over N: Status 404 triggered\nerror_page 404 /404.html
    N->>N: Internal redirect to GET /404.html
    N->>F: Look for file: /var/www/html/404.html
    F-->>N: File exists
    N->>B: HTTP/1.1 404 Not Found\nContent-Type: text/html\n[404.html content]

Key point: the browser still receives the 404 status code — not 200. Nginx only changes the content returned, not the status code (unless you explicitly change it, which we’ll discuss later).


Basic Syntax #

server {
    root /var/www/html;

    # One status code → one file
    error_page 404 /404.html;

    # Multiple status codes → the same file
    error_page 500 502 503 504 /50x.html;

    # Protect the error file from direct access
    # (users can't access http://example.com/404.html directly)
    location = /404.html {
        internal;
    }

    location = /50x.html {
        internal;
    }
}

The internal directive in the error page location block prevents users from accessing those files directly from a browser. Without internal, someone could access http://example.com/404.html and get a 200 OK response — confusing and problematic for SEO.


Storing Error Pages in a Separate Directory #

For easier management, keep all error pages in one dedicated directory:

server {
    root /var/www/html;

    # All error pages handled here
    error_page 400 /errors/400.html;
    error_page 401 /errors/401.html;
    error_page 403 /errors/403.html;
    error_page 404 /errors/404.html;
    error_page 405 /errors/405.html;
    error_page 408 /errors/408.html;
    error_page 429 /errors/429.html;
    error_page 500 502 503 504 /errors/50x.html;

    # One location for all error files
    # Files are at /var/www/html/errors/
    location ^~ /errors/ {
        internal;   # Can only be accessed as an internal redirect
        root /var/www/html;
        # Path: root + URI = /var/www/html + /errors/404.html
    }

    # Or if the error files live in a location separate from the web root:
    location ^~ /errors/ {
        internal;
        alias /opt/nginx-error-pages/;
        # Path: alias + (URI after /errors/) = /opt/nginx-error-pages/404.html
    }
}

Changing the Response Status Code #

By default, error_page preserves the original status code. But you can change it using the = operator:

server {
    # ─── Default: show the 404 page, return status 404 ────────────────
    error_page 404 /404.html;

    # ─── Show the page but change the status code ─────────────────────────────

    # Change 404 → 200 (useful for SPAs that need all paths
    # to return index.html with status 200)
    error_page 404 =200 /index.html;

    # Change 403 → 404 (hide the existence of a forbidden resource)
    # Attackers can't tell whether a resource exists but is forbidden, or doesn't exist at all
    error_page 403 =404 /404.html;

    # Change server errors to 503 Service Unavailable
    # (useful during maintenance)
    error_page 500 502 504 =503 /maintenance.html;
}

Special Case: SPAs with error_page #

SPA frameworks like React, Vue, and Angular handle routing on the client side. All paths (including ones that “don’t exist” from the server’s perspective) must return index.html:

server {
    root /var/www/spa;

    location / {
        try_files $uri $uri/ /index.html;
        # The more recommended way for SPAs
    }

    # Alternative using error_page (less recommended):
    error_page 404 =200 /index.html;
    # Problem: all 404s become 200, including missing assets (CSS, JS, images)
    # which should stay 404 so browsers don't cache them incorrectly
}

Errors from the Backend: proxy_intercept_errors #

When Nginx is used as a reverse proxy, errors can come from the backend (a Node.js, Python, PHP application, etc.). By default, Nginx forwards the backend’s error pages straight to the browser.

With proxy_intercept_errors on, Nginx takes over and shows your custom error page:

server {
    location / {
        proxy_pass http://backend;

        # Enable intercepting backend errors
        proxy_intercept_errors on;

        # Now the error_page directives in this server also apply to backend errors
        error_page 502 503 504 /maintenance.html;
        error_page 500 /500.html;
    }

    location = /maintenance.html {
        root /var/www/html;
        internal;
    }

    location = /500.html {
        root /var/www/html;
        internal;
    }
}
flowchart LR
    B["Browser"] --> N["Nginx\nReverse Proxy"]
    N --> BK["Backend\nNode.js, Python, or PHP"]
    BK -- "502 error from backend" --> N
    N -- "proxy_intercept_errors on\n502 error intercepted" --> EP["Fetch /maintenance.html\nReturn it to the browser"]
    EP --> B

With vs Without proxy_intercept_errors #

Without proxy_intercept_errors:
  Browser ← Nginx ← Backend sends its own error page (Express.js error template, etc.)

With proxy_intercept_errors on:
  Browser ← Nginx shows our /maintenance.html
              (the backend error page is ignored)

Named Locations as Error Handlers #

For more complex error logic, you can use named locations:

server {
    location / {
        proxy_pass http://backend;
        proxy_intercept_errors on;

        error_page 502 503 @maintenance;
        error_page 500 @server_error;
    }

    location @maintenance {
        root /var/www/html;
        try_files /maintenance.html =503;

        # Set extra headers
        add_header Retry-After 3600;
        add_header Cache-Control "no-store";
    }

    location @server_error {
        root /var/www/html;
        try_files /500.html =500;

        # Log unexpected errors
        access_log /var/log/nginx/500-errors.log;
    }
}

Redirecting to an External URL on Error #

error_page can also redirect to an external URL, though this changes the status code to a redirect (301/302):

# Redirect to an external status page
error_page 503 https://status.example.com;

# Note: this returns a 302 to the browser,
# not a 503. The browser follows the redirect.

# Better: use an internal redirect to a local file
# that can pull content from the status page if needed

Hiding the Nginx Version #

Nginx’s built-in error pages show the version by default:

<hr><center>nginx/1.24.0</center>

This gives attackers unnecessary information — they can search for CVEs specific to that version. Turn it off with server_tokens:

http {
    # Hide the Nginx version from:
    # - The "Server" header in every response
    # - Nginx's built-in error pages
    server_tokens off;

    # With server_tokens off:
    # Header: Server: nginx   (no version)
    # Error page: nginx       (no version)
}

If you want to completely hide that the server uses Nginx (not just its version):

# With Nginx Plus or the nginx-headers-more module:
more_set_headers 'Server: MyApp';

# Without an extra module, you can only hide the version, not the server name
# (unless you compile Nginx with a custom build)

HTML Error Page Templates #

Here are examples of professional, responsive, informative error page templates:

404 Not Found #

<!-- /var/www/html/errors/404.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>404 — Page Not Found</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #f8fafc;
            color: #334155;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 2rem;
        }
        .container {
            text-align: center;
            max-width: 500px;
        }
        .code {
            font-size: 8rem;
            font-weight: 900;
            color: #e2e8f0;
            line-height: 1;
            margin-bottom: 1rem;
        }
        h1 { font-size: 1.5rem; margin-bottom: 0.75rem; }
        p { color: #64748b; margin-bottom: 2rem; line-height: 1.6; }
        .btn {
            display: inline-block;
            background: #3b82f6;
            color: white;
            padding: 0.75rem 2rem;
            border-radius: 8px;
            text-decoration: none;
            font-weight: 600;
            transition: background 0.2s;
        }
        .btn:hover { background: #2563eb; }
    </style>
</head>
<body>
    <div class="container">
        <div class="code">404</div>
        <h1>Page not found</h1>
        <p>The page you're looking for may have been moved, deleted, or the URL you typed is incorrect.</p>
        <a href="/" class="btn">Back to Home</a>
    </div>
</body>
</html>

50x Server Error #

<!-- /var/www/html/errors/50x.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Something Went Wrong — Try Again Later</title>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: #fff7f7;
            color: #334155;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 2rem;
        }
        .container { text-align: center; max-width: 500px; }
        .icon { font-size: 4rem; margin-bottom: 1.5rem; }
        h1 { font-size: 1.5rem; margin-bottom: 0.75rem; color: #dc2626; }
        p { color: #64748b; margin-bottom: 1rem; line-height: 1.6; }
        .btn {
            display: inline-block;
            background: #dc2626;
            color: white;
            padding: 0.75rem 2rem;
            border-radius: 8px;
            text-decoration: none;
            font-weight: 600;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="icon">⚠️</div>
        <h1>The server ran into a problem</h1>
        <p>The server is experiencing a temporary issue and can't process your request right now.</p>
        <p>Our team has been notified and is working on it. Please try again in a few minutes.</p>
        <a href="javascript:location.reload()" class="btn">Try Again</a>
    </div>
</body>
</html>

Complete Production error_page Configuration #

Here’s a comprehensive error_page configuration for a production server:

server {
    listen 443 ssl;
    http2  on;
    server_name example.com;

    root /var/www/example.com;

    # ─── Security: hide the Nginx version ────────────────────────────────────
    server_tokens off;

    # ─── Error pages for various status codes ───────────────────────────────
    error_page 400 /errors/400.html;      # Bad Request
    error_page 401 /errors/401.html;      # Unauthorized
    error_page 403 /errors/403.html;      # Forbidden
    error_page 404 /errors/404.html;      # Not Found
    error_page 408 /errors/408.html;      # Request Timeout
    error_page 429 /errors/429.html;      # Too Many Requests (rate limiting)
    error_page 500 /errors/500.html;      # Internal Server Error
    error_page 502 /errors/502.html;      # Bad Gateway (backend down)
    error_page 503 /errors/503.html;      # Service Unavailable (maintenance)
    error_page 504 /errors/504.html;      # Gateway Timeout

    # ─── Location for all error pages ────────────────────────────────────
    location ^~ /errors/ {
        internal;
        root /var/www/example.com;
        # Files are at /var/www/example.com/errors/404.html etc.
    }

    # ─── Main routing ─────────────────────────────────────────────────────────
    location / {
        proxy_pass http://backend;
        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;

        # Intercept backend errors and show our custom page
        proxy_intercept_errors on;

        # Reasonable timeouts
        proxy_connect_timeout  5s;
        proxy_read_timeout    60s;
        proxy_send_timeout    60s;
    }

    # ─── Maintenance mode: activate with a flag file ──────────────────────────
    # Create /var/www/example.com/maintenance.flag to enter maintenance mode
    set $maintenance 0;
    if (-f $document_root/maintenance.flag) {
        set $maintenance 1;
    }

    location / {
        if ($maintenance = 1) {
            return 503;
        }
        proxy_pass http://backend;
        proxy_intercept_errors on;
        error_page 503 /errors/503.html;
    }
}

Debugging error_page That Isn’t Working #

There are several cases that make error_page not work as expected:

Case 1: Recursion Protection #

If the error page file itself produces an error, Nginx won’t infinite loop — but it also won’t show further error pages:

# Check whether the error page file exists and is readable
ls -la /var/www/html/errors/404.html
cat /var/www/html/errors/404.html | head -5

# Check the error log for "open() ... failed"
tail -f /var/log/nginx/error.log

Case 2: error_page Doesn’t Apply to Backend Errors #

# WRONG: proxy_intercept_errors is not active
location / {
    proxy_pass http://backend;
    error_page 502 /maintenance.html;  # Won't work!
}

# CORRECT: proxy_intercept_errors must be active
location / {
    proxy_pass http://backend;
    proxy_intercept_errors on;  # ← This is what activates it
    error_page 502 /maintenance.html;
}

Case 3: The error_page File Doesn’t Exist #

# Nginx will log an error and may show its built-in page
# if the file referenced in error_page doesn't exist:
# [error] open() "/var/www/html/404.html" failed (2: No such file or directory)

Case 4: Conflict with try_files #

location / {
    # try_files returns =404 internally
    # this is NOT a regular HTTP 404 — it's a "pseudo-request"
    # handled differently
    try_files $uri $uri/ =404;
    # error_page 404 WILL be triggered by this
}

Summary #

  • error_page 404 /404.html — the basic directive for setting custom error pages per status code. Nginx performs an internal redirect to that URI.
  • Always add internal; in the location block serving error pages — prevents direct access from browsers.
  • proxy_intercept_errors on is required for Nginx to show custom error pages when the backend returns errors — without it, the backend’s error pages are forwarded directly.
  • server_tokens off in the http context hides the Nginx version from the Server header and built-in error pages — mandatory in production.
  • error_page 403 =404 hides the existence of a forbidden resource — attackers can’t tell whether a resource doesn’t exist or exists but is blocked.
  • error_page 404 =200 /index.html for SPAs, but try_files $uri $uri/ /index.html is more recommended because it’s more specific (only non-existent paths, not all 404s).
  • Use ^~ /errors/ + internal to manage all error pages in one directory.

← Previous: Index & Autoindex   Next: Reverse Proxy Concepts →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact