Nginx Custom Error Pages: Enhance User Experience

Configure useful Nginx custom error pages for 404, 403, and 50x responses without hiding real failures.

Nginx Custom Error Pages: Enhance User Experience

Nginx custom error pages turn a raw failure into a clear next step. They do not fix the broken link, the missing file, or the crashed upstream app. They do something smaller but still valuable: they tell the visitor what happened in plain language and keep them from feeling dumped out of your site.

That matters during ordinary mistakes. Someone follows an old documentation link and gets a 404. A private file path returns 403. Your app restarts during deployment and Nginx briefly sees 502. Without a custom page, users may see a default server response that looks abrupt or technical. With a good static page, they know whether to search, go back, retry, or wait.

The best error pages are boring operational tools. They are static, fast, accessible, and honest. They do not hide outages from monitoring, and they do not expose internal details to users.

Start with the errors people actually see

You do not need to design a page for every HTTP status code. Start with the common ones.

404 Not Found is the first page to customize. It appears when the requested URL does not match a file, route, or Nginx location that returns content. Old links, renamed posts, deleted documentation pages, and hand-typed URLs all lead here.

A useful 404 page says something like: "We could not find that page." Then it offers a path back to the homepage, documentation index, product area, or search page. Do not blame the user. The URL may have been wrong long before they clicked it.

403 Forbidden is different. Nginx understood the request but will not serve it. Causes include file permissions, access rules, disabled directory listing, IP allow/deny rules, or auth requirements. A 403 page should be calm and short. If the resource is private, say so. If users may need access, point them to the right login or support path.

For app-backed sites, handle 50x errors carefully:

  • 500 Internal Server Error usually means the application failed while processing the request.
  • 502 Bad Gateway often means Nginx did not receive a valid response from the upstream service.
  • 503 Service Unavailable is useful for maintenance, overload, or a deliberately unavailable service.
  • 504 Gateway Timeout means Nginx waited too long for the upstream response.

A SaaS dashboard example: your app process restarts during deployment. For a few seconds, Nginx cannot connect to the upstream and users see a 502. A custom page can say, "The dashboard is temporarily unavailable. Please refresh in a minute." That is not perfect, but it is clearer than a default gateway error.

Create static error files

Keep error pages outside the application dependency chain. If the database is down, the 500 page should still load. If the Node, Python, Ruby, or PHP app is unhealthy, Nginx should still serve the static fallback.

A simple file layout might be:

/var/www/example.com/public/
  index.html
  assets/
/var/www/example.com/errors/
  404.html
  403.html
  50x.html

Keep the HTML lightweight. Avoid third-party scripts, heavy images, client-side app bundles, and anything that calls the broken backend. A small CSS file is fine if Nginx can serve it directly.

A minimal 404.html could be:

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Page not found</title>
</head>
<body>
  <main>
    <h1>Page not found</h1>
    <p>The page may have moved, or the link may be out of date.</p>
    <p><a href="/">Go to the homepage</a></p>
  </main>
</body>
</html>

That is enough. You can style it to match your site, but the message and links matter more than decoration.

Wire the pages with error_page

In Nginx, the error_page directive maps one or more status codes to a URI. A basic server block looks like this:

server {
    listen 80;
    server_name example.com www.example.com;

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

    error_page 404 /errors/404.html;
    error_page 403 /errors/403.html;
    error_page 500 502 503 504 /errors/50x.html;

    location /errors/ {
        internal;
        root /var/www/example.com;
    }

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

The path resolution is the part that trips people. In this example, requests under /errors/ use root /var/www/example.com;, so /errors/404.html maps to /var/www/example.com/errors/404.html.

The internal directive means outside clients cannot request /errors/404.html directly as a normal URL. Nginx can still serve it internally when handling an error.

After editing the config, test and reload:

sudo nginx -t
sudo systemctl reload nginx

Then test behavior:

curl -I http://example.com/definitely-missing-page
curl -s http://example.com/definitely-missing-page | head

The status should still be 404, even though the body is your friendly HTML. A common mistake is accidentally returning 200 OK for a missing page, which makes monitoring and search engines think the missing URL is a real page.

Custom pages behind a reverse proxy

If Nginx proxies to an upstream application, the app may return its own error responses. By default, proxied responses are usually passed back to the client. To have Nginx intercept upstream error responses and use your error_page rules, enable proxy_intercept_errors in the relevant context.

location /app/ {
    proxy_pass http://app_backend;
    proxy_intercept_errors on;

    error_page 502 503 504 /errors/50x.html;
}

Nginx documentation describes proxy_intercept_errors as applying to proxied responses with status codes greater than or equal to 300, which can then be redirected to Nginx for error_page processing. In practice, do not turn it on everywhere without thinking.

For browser pages, intercepting 502 or 503 is often useful. For JSON APIs, it may be wrong. API clients usually expect a structured JSON error body, not an HTML page. You may need separate locations:

location /api/ {
    proxy_pass http://api_backend;
    proxy_intercept_errors off;
}

location /dashboard/ {
    proxy_pass http://app_backend;
    proxy_intercept_errors on;
    error_page 502 503 504 /errors/50x.html;
}

That split keeps human-facing pages friendly while preserving machine-readable API errors.

Preserve the right status code

Nginx lets you change the response code with error_page, but do it only when you mean it. This is valid syntax:

error_page 404 =200 /fallback.html;

For most websites, that would be a bad idea. A missing page should remain a 404. Search engines, uptime checks, analytics, and users all benefit from the truth.

There are legitimate cases for changing codes, such as routing certain errors to a named location or returning a maintenance page with 503. But as a default rule, preserve the original error status.

For maintenance, you can be explicit:

location / {
    return 503;
}

error_page 503 /errors/maintenance.html;

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

If you use a CDN or load balancer in front of Nginx, remember that it may have its own error page behavior. Decide which layer owns which errors. Otherwise, you may test Nginx directly and see one page, while users behind the CDN see another.

Write error pages for humans

The content should answer three questions quickly:

  • What happened?
  • Is it temporary?
  • What can I do next?

For a 404, useful next steps are search, homepage, docs index, or contact support if the missing page should exist. For a 503, useful guidance is retry later or check a status page. For a 403, point to sign-in or access-request instructions if appropriate.

Avoid stack traces, upstream hostnames, filesystem paths, package versions, internal IPs, request IDs with no explanation, and incident details. A request ID can be useful if support can use it, but label it clearly:

<p>If you contact support, include this request ID: <code>$request_id</code></p>

To inject variables like $request_id, you need a configuration pattern that supports it. Static HTML files will not expand Nginx variables by themselves. Many teams keep public error pages static and rely on logs for request IDs instead.

Accessibility is part of usefulness. Use one clear h1, readable contrast, normal links, and plain text. Do not make the only recovery action a tiny icon or a script-powered button.

Test the pages on purpose

Do not wait for users to find your error pages. Test them after every change.

For 404:

curl -i https://example.com/no-such-page

For 403, create a controlled test location or use a private test file. Do not loosen production permissions just to trigger an error.

For 502 or 503, test in staging by pointing a location at an unavailable upstream:

location /broken-upstream-test/ {
    proxy_pass http://127.0.0.1:59999;
    proxy_intercept_errors on;
    error_page 502 503 504 /errors/50x.html;
}

Then request it and confirm both the status code and body:

curl -i https://staging.example.com/broken-upstream-test/

Also watch logs:

sudo tail -f /var/log/nginx/error.log /var/log/nginx/access.log

A good-looking error page should not erase the operational signal. Your alerts should still fire when 50x rates rise.

Nginx custom error pages are a small configuration task with a real user impact. Start with 404, 403, and the common 50x errors. Serve static files directly from Nginx. Preserve accurate status codes. Use proxy_intercept_errors only where HTML fallbacks make sense. Then test the pages the same way you test any other production behavior.