A default Nginx 404 page tells a visitor almost nothing useful — just a bare “404 Not Found” in the browser’s default styling, with no navigation, no branding, nothing pointing them back toward the content they were actually looking for. I’ve made it a habit on every site I set up to replace those default error pages with something that matches the site and actually helps the visitor recover. It’s a small thing, but it noticeably changes how a broken link feels to a user — the difference between “this site is broken” and “oh, easy fix, let me go back home.”
This guide covers how to configure custom error pages in Nginx for the most common status codes, how to make them dynamic-aware (so they still work if your backend is down), testing, and the details that catch people off guard — like error pages that themselves 404 because of a path mistake.
Why Custom Error Pages Matter
Beyond aesthetics, custom error pages serve real functional purposes:
- User retention. A styled 404 with a search box or navigation links keeps visitors on your site instead of bouncing immediately.
- Brand consistency. An error page that looks like it belongs to your site (not a generic server default) reinforces trust rather than undermining it.
- Debugging clarity for internal tools. For internal or staging environments, a custom 502/503 page can include useful context for your team without exposing that detail publicly.
- SEO hygiene. Proper status codes with clean, crawlable error pages help search engines understand what’s actually missing versus what’s a temporary outage.
Prerequisites
- A working Nginx server block.
- Sudo access to edit configuration and reload Nginx.
- Basic HTML/CSS skills to build the actual error page content (I’ll provide simple examples).
Understanding the error_page Directive
The core directive is straightforward:
error_page 404 /404.html;
This tells Nginx: whenever a request would result in a 404, serve /404.html instead — while (by default) still returning a 404 HTTP status code to the client, which matters for SEO and correctness.
You can also handle multiple codes with one directive:
error_page 500 502 503 504 /50x.html;
Step-by-Step Configuration
Step 1: Create Your Error Page Files
I keep these in a dedicated directory to keep things organized:
sudo mkdir -p /var/www/example.com/errors
A simple 404 page:
sudo nano /var/www/example.com/errors/404.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Page Not Found</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 80px 20px; background: #f9f9f9; }
h1 { font-size: 72px; color: #333; margin-bottom: 10px; }
p { font-size: 18px; color: #666; }
a { color: #2b6cb0; text-decoration: none; font-weight: bold; }
</style>
</head>
<body>
<h1>404</h1>
<p>Sorry, the page you're looking for doesn't exist.</p>
<p><a href="/">Go back home</a></p>
</body>
</html>
And a 50x page for server errors:
sudo nano /var/www/example.com/errors/50x.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Something Went Wrong</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 80px 20px; background: #fff5f5; }
h1 { font-size: 64px; color: #c53030; }
p { font-size: 18px; color: #666; }
</style>
</head>
<body>
<h1>Oops</h1>
<p>We're experiencing a temporary issue. Please try again in a few minutes.</p>
</body>
</html>
Step 2: Reference Them in Your Server Block
server {
listen 80;
server_name example.com www.example.com;
root /var/www/example.com/public;
index index.html;
error_page 404 /404.html;
error_page 500 502 503 504 /50x.html;
location = /404.html {
root /var/www/example.com/errors;
internal;
}
location = /50x.html {
root /var/www/example.com/errors;
internal;
}
location / {
try_files $uri $uri/ =404;
}
}
The internal; directive is important — it prevents anyone from accessing /404.html or /50x.html directly as a normal URL. They should only ever be reached through Nginx’s internal error redirection, not typed directly into a browser.
Step 3: Test and Reload
sudo nginx -t
sudo systemctl reload nginx
Testing Your Error Pages
Trigger a 404 directly:
curl -I http://example.com/this-page-does-not-exist
You should see:
HTTP/1.1 404 Not Found
Then open that same URL in a browser and confirm your styled page appears instead of the plain default.
Trigger a 50x error for testing. The cleanest way to test this without actually breaking your server is to temporarily point proxy_pass at a nonexistent backend, or stop a backend service if you’re using Nginx as a reverse proxy:
sudo systemctl stop myapp
curl -I http://example.com/
sudo systemctl start myapp
You should get a 502 Bad Gateway or 504 Gateway Timeout, and your custom error page should render.
Confirm status codes are still accurate. This is easy to get wrong. Check that the HTTP status code returned is still 404 or 502, not 200 — a custom error page should never silently return success:
curl -s -o /dev/null -w "%{http_code}\n" http://example.com/nonexistent-page
A More Complete, Real-World Configuration
Here’s a fuller setup that includes a reverse-proxied backend, so you can see how error pages interact with proxy_pass failures:
server {
listen 80;
server_name app.example.com;
root /var/www/app.example.com/public;
error_page 400 401 403 /errors/4xx.html;
error_page 404 /errors/404.html;
error_page 500 502 503 504 /errors/50x.html;
location = /errors/4xx.html {
root /var/www/app.example.com;
internal;
}
location = /errors/404.html {
root /var/www/app.example.com;
internal;
}
location = /errors/50x.html {
root /var/www/app.example.com;
internal;
}
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_intercept_errors on;
}
location / {
try_files $uri $uri/ /index.html;
}
}
The key line here is proxy_intercept_errors on;. By default, when Nginx proxies a request and the backend itself returns an error page (say, your Node.js app returns its own ugly 500 page), Nginx passes that backend response straight through to the client — your custom error_page directive is ignored. Setting proxy_intercept_errors on; tells Nginx to intercept error status codes from the backend and substitute your custom error pages instead. This one setting trips up a lot of people who can’t figure out why their custom 502 page isn’t showing up.
Handling Errors Differently Per Section of a Site
Sometimes I want different error pages for different parts of a site — say, a JSON API returning a JSON error body instead of HTML:
location /api/ {
proxy_pass http://127.0.0.1:3000;
proxy_intercept_errors on;
error_page 404 = @api_404;
error_page 500 502 503 504 = @api_50x;
}
location @api_404 {
default_type application/json;
return 404 '{"error": "Not Found"}';
}
location @api_50x {
default_type application/json;
return 502 '{"error": "Service temporarily unavailable"}';
}
The = after error_page lets you override the status code returned in the named location — useful if you want the named location to return a specific code regardless of which upstream error triggered it.
Troubleshooting Common Issues
Custom error page not showing, default Nginx page still appears. Check that the location block path exactly matches the path in error_page, and confirm the file actually exists at the root you specified. A typo here is the single most common cause.
Error page shows but returns HTTP 200 instead of the real error code. This happens if you used return 200 somewhere in the error location by mistake, or if the file is being served through a location block that doesn’t preserve the original error status. Double check with curl -I as shown above — the status line should match the actual error, not 200.
Backend’s own error page shows instead of your custom one. As covered above, add proxy_intercept_errors on; to the relevant location block.
“14: circular reference in configuration” error when reloading. This happens if your error page location itself triggers another error (for example, the error page file doesn’t exist, causing another 404, which tries to load the error page again). Double-check the file path and permissions.
Custom 404 page accessible directly at /404.html. Add internal; to the location block — this was covered in Step 2, but it’s easy to forget and leaves the page publicly browsable as its own route, which is usually harmless but not intended.
Security Considerations
- Never leak stack traces or debug info in production error pages. If your backend framework is in debug mode and returning verbose error output, make sure
proxy_intercept_errors on;is active so Nginx swaps that out for your generic page before it reaches the client. - Keep error pages simple and static. Don’t have your error pages themselves depend on a database query or backend call — if the reason you’re showing an error page is that the backend is down, an error page that also depends on the backend will fail too.
- Avoid revealing internal architecture. A default Nginx error page sometimes reveals the Nginx version number. Turn that off globally:
server_tokens off;
- Rate limit repeated error triggers if abused. If bots are hammering nonexistent URLs looking for vulnerabilities, that’s a
404flood — consider combining withlimit_reqif it becomes a resource concern.
Performance Tips
- Keep error pages lightweight. No heavy JavaScript frameworks, no large images — the whole point is these pages load fast and reliably, even under conditions where the rest of your infrastructure might be struggling.
- Serve error pages from local disk, not a proxied backend. This is naturally how the
internal;pattern above works — even if your entire application backend is down, Nginx can still serve a static error page directly from disk without any dependency on the failing service. - Cache error pages briefly if you expect high error traffic (like a botnet scanning for old WordPress paths), to avoid unnecessary disk reads:
location = /errors/404.html {
root /var/www/example.com;
internal;
expires 5m;
}
Real-World Use Cases
- An e-commerce site where the 404 page included a live search bar and “recently viewed products,” meaningfully recovering lost visitors instead of losing the sale entirely.
- A SaaS app where I split error handling between HTML pages for the main site and JSON error bodies for the
/api/prefix, since the frontend JavaScript needed structured error data, not an HTML page. - A maintenance-mode setup, where I temporarily forced all traffic to a “we’ll be right back” 503 page during a planned migration, using
return 503;combined witherror_page 503 /maintenance.html;and aninternal;block, then reverted once the migration finished. - A high-traffic blog, where custom 404s included a “search this site” widget, which analytics later showed recovered a meaningful percentage of otherwise-lost visits from broken external links.
Best Practices Summary
- Use
error_pagewith dedicated static HTML files stored outside your normal document root logic, markedinternal. - Always verify the actual HTTP status code with
curl -I, not just visual appearance in a browser. - Add
proxy_intercept_errors on;for any location usingproxy_passif you want your custom pages to override backend errors. - Keep error pages fully static and dependency-free so they work even during real outages.
- Consider separate error handling for API routes (JSON) versus browser-facing routes (HTML).
- Hide version and stack trace details from error responses.
Custom error pages are one of those details that costs almost nothing to implement properly but consistently makes a site feel more polished and trustworthy. Once the pattern’s in place, it’s just a couple of lines per site to reuse it everywhere.
Building a More Functional 404 Page
A static “page not found” message is the bare minimum. Over time I’ve added a few things to my own 404 templates that measurably help visitors recover instead of just bouncing:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Page Not Found</title>
<style>
body { font-family: sans-serif; text-align: center; padding: 80px 20px; }
h1 { font-size: 64px; color: #333; }
input { padding: 10px; width: 260px; font-size: 16px; }
button { padding: 10px 16px; font-size: 16px; }
.links a { margin: 0 10px; color: #2b6cb0; }
</style>
</head>
<body>
<h1>404</h1>
<p>We couldn't find that page. Try searching instead:</p>
<form action="/search" method="get">
<input type="text" name="q" placeholder="Search the site...">
<button type="submit">Search</button>
</form>
<p class="links">
<a href="/">Home</a> · <a href="/blog">Blog</a> · <a href="/contact">Contact</a>
</p>
</body>
</html>
A search box and a small set of navigation links cost nothing to add and give a lost visitor an actual path forward instead of a dead end. For sites with analytics, I’ve also seen real value in tracking 404 events specifically (most analytics platforms support a virtual pageview or custom event fired from the error page itself) — reviewing which URLs generate the most 404s regularly surfaces broken internal links or outdated external references worth fixing at the source.
Logging Errors Separately for Easier Monitoring
I often route error responses to a separate log file so I can monitor them without wading through normal traffic logs:
map $status $loggable {
~^[23] 0;
default 1;
}
server {
access_log /var/log/nginx/access.log combined if=$loggable;
access_log /var/log/nginx/errors.log combined if=$loggable;
...
}
This map block flags any 4xx or 5xx response as “loggable” for the errors-specific log, while 2xx/3xx responses skip it — giving me a log file that’s exclusively error traffic, which is much faster to scan during an incident than filtering a full access log by status code after the fact. Combined with a simple tail -f during a deploy, this makes it obvious immediately if something is returning unexpected errors.
Frequently Asked Questions
Should every error code get its own custom page? Not necessarily. I typically build dedicated pages for 404 (most common, most user-facing) and a combined 50x page for server errors (500/502/503/504), since users generally don’t need — or benefit from — a different message for “gateway timeout” versus “internal server error.” A dedicated 403 page can be worth it for sites with access-controlled areas, so visitors understand it’s a permissions issue rather than a broken link.
Do search engines treat custom error pages differently than default ones? No — what matters to search engines is the actual HTTP status code, not the page’s visual content. As long as you’re correctly returning 404 (not 200) for missing pages, a custom design has no negative SEO impact and, if anything, tends to reduce bounce rate, which is a positive signal.
Can I redirect 404s to the homepage instead of showing an error page? You can, but I generally advise against it — silently redirecting every broken link to the homepage with a 301 or 302 masks real problems (search engines and site owners lose visibility into what’s actually broken) and can confuse visitors who don’t understand why they landed somewhere unrelated to what they clicked. A proper 404 with clear next steps is almost always the better experience.
What about a maintenance page during deployments? That’s a great use of the same error_page mechanism, using a manually triggered 503 (as mentioned earlier in this guide) rather than waiting for an actual failure — it lets you show visitors an intentional, informative “back soon” message instead of a raw connection error during planned downtime.
A Full Maintenance-Mode Pattern
Since this comes up often enough, it’s worth walking through the complete pattern I actually use for planned maintenance windows, since it ties several of the concepts in this guide together in a practical way:
server {
listen 80;
server_name example.com;
root /var/www/example.com/public;
set $maintenance 0;
if (-f /var/www/example.com/maintenance.flag) {
set $maintenance 1;
}
if ($maintenance = 1) {
return 503;
}
error_page 503 @maintenance;
location @maintenance {
root /var/www/example.com/errors;
rewrite ^(.*)$ /maintenance.html break;
internal;
}
location / {
try_files $uri $uri/ /index.php?$args;
}
}
The trick here is the flag file: touching /var/www/example.com/maintenance.flag flips the whole site into maintenance mode instantly, and removing it brings the site back — no Nginx reload required, since the check happens per-request via a simple file existence test.
# Enter maintenance mode
sudo touch /var/www/example.com/maintenance.flag
# Exit maintenance mode
sudo rm /var/www/example.com/maintenance.flag
I usually add one more refinement: exempting my own IP so I can still test the live site while everyone else sees the maintenance page, using the same allow/deny pattern covered in the IP whitelisting guide:
if ($maintenance = 1) {
set $maintenance_final "${maintenance}${allowed_ip}";
}
paired with a geo block defining $allowed_ip for trusted testing IPs — combining maintenance mode with an access-control exception is a pattern I reuse across nearly every client deployment, since it turns what would otherwise be a slightly nerve-wracking “the whole site is down for everyone including me” moment into a controlled, verifiable rollout.
Error Pages for Non-HTML Content Types
Beyond HTML and JSON, I occasionally need error handling for other content types — an RSS feed endpoint, for instance, should return valid (if minimal) XML on error, not an HTML error page that would break any feed reader trying to parse it:
location /feed/ {
error_page 404 500 502 503 = @feed_error;
}
location @feed_error {
default_type application/rss+xml;
return 503 '<?xml version="1.0"?><rss><channel><title>Feed temporarily unavailable</title></channel></rss>';
}
The general principle carries across any content type: match the error response format to what the consuming client actually expects, rather than defaulting to HTML everywhere regardless of context.