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 --> BWith 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 onis 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 offin thehttpcontext hides the Nginx version from theServerheader and built-in error pages — mandatory in production.error_page 403 =404hides 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.htmlfor SPAs, buttry_files $uri $uri/ /index.htmlis more recommended because it’s more specific (only non-existent paths, not all 404s).- Use
^~ /errors/+internalto manage all error pages in one directory.
← Previous: Index & Autoindex Next: Reverse Proxy Concepts →