Custom 404 Page in .htaccess — ErrorDocument, Done Right

ErrorDocument syntax that keeps the real status code: the external-URL 302 trap, the 401 special case, AllowOverride, and curl verification.

ErrorDocument is a one-line feature: a missing URL, your own page, the real status code. All the ways it goes wrong live in the target — which of the three argument forms Apache thinks you wrote.

The correct form

# Local path, absolute from the document root — status stays 404.
ErrorDocument 404 /errors/404.html

Three facts carry the line:

  • The leading / is not optional. A path without it is neither a URL-path nor a URL, so Apache treats the whole argument as a text message — the visitor sees the literal string errors/404.html as the error body instead of the file.
  • The file must exist and be readable by Apache. A missing or permission-blocked error document falls back to the default Apache page — silently, which is worse than a boring page because it looks like your directive is being ignored entirely.
  • The status code survives. A local path is served as the body of the 404 response — crawlers see HTTP/2 404 and your HTML at the same time. That combination is the entire point.

The 302 trap: external URLs

# DON'T — Apache can't serve a remote file, so it redirects to it.
ErrorDocument 404 https://example.com/oops.html

An http(s):// target turns the error into a 302 redirect to that URL. The original 404 status — the thing that tells search engines a page is gone and API clients a resource doesn’t exist — is replaced by “go look over there.” Google reads mass cases of this as soft-404s. Keep targets local; the generator’s error-document rows accept external URLs but print exactly this warning in the output when you use one.

The 401 exception — the one code that must be local

ErrorDocument 401 is the document shown while a browser is deciding whether to retry with credentials. If it points at a URL — external or absolute — Apache can’t attach it to the auth challenge correctly, and you get an endless Basic-auth popup loop. Apache logs “cannot use a full URL in a 401 ErrorDocument directive” for it. Any other 4xx/5xx still prefers a local path, but 401 requires one.

More than 404

The same syntax covers every error status the site can emit — 403, 500, 503 are the usual set:

ErrorDocument 403 /errors/403.html
ErrorDocument 404 /errors/404.html
ErrorDocument 500 /errors/500.html

The 503 row unlocks a real maintenance page. Return 503 for everything but your own IP, let ErrorDocument 503 style it, and send crawlers a Retry-After so they come back instead of deindexing a temporary outage:

ErrorDocument 503 /maintenance.html
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteCond %{REMOTE_ADDR} !^203\.0\.113\.10$   # your IP — sees the real site
RewriteRule ^ - [R=503,L]
</IfModule>
<IfModule mod_headers.c>
# Inside a maintenance block every response is a 503, so an
# unconditional Retry-After is correct here — not sloppy.
Header always set Retry-After "3600"
</IfModule>

R=503 doesn’t redirect anywhere — it answers with the status and your ErrorDocument 503 page. Retry-After (seconds, or an HTTP date) is the polite signal that separates “down for maintenance” from “gone.”

Why it might silently not work

  • AllowOverride — ErrorDocument needs FileInfo; locked-down hosts answer every request under it with 500. The 500 guide maps the classes.
  • Priority by proximity — an ErrorDocument in /blog/.htaccess covers /blog/… misses and overrides the root file for that subtree only.
  • Cached 301s masquerade as broken 404s — if a “fixed” URL still redirects, it’s your browser replaying a permanent redirect, not the server.

Verify the real status, not the rendered page:

curl -I https://yoursite.example/definitely-missing   # want: HTTP/2 404
curl -s https://yoursite.example/definitely-missing   # body = your HTML

Build the whole set in the generator — the Custom error pages section emits validated ErrorDocument lines and flags the external-URL trap before you paste it. For scope beyond status codes — hiding a directory listing, blocking visitors — see blocking IPs in .htaccess and the mod_rewrite basics.

Frequently asked questions

My ErrorDocument URL doesn't start with a slash — does it still work?

Not the way you expect. Apache reads the argument as one of three things: a local path starting with /, a full http(s):// URL, or — failing both — literal text to show as the error body. ErrorDocument 404 errors/404.html displays the characters "errors/404.html" to the visitor instead of your page.

Why did my 404 turn into a 302 in Search Console?

Because the ErrorDocument points at an absolute URL on another host. Apache can't serve a remote file for the error, so it answers the error request with a 302 redirect to that URL. Crawlers and API clients record a redirect, not a 404 — the soft-404 problem. Point it at a local path like /errors/404.html and the real status is preserved.

Can ErrorDocument live in a subdirectory's .htaccess?

Yes — it scopes to that subtree, and it doesn't have to live in the docroot file. A /blog/.htaccess can point at /blog/404.html while the rest of the site uses the root rule. The path is still written from the document root, not relative to the subdirectory.

Does ErrorDocument need a module or an AllowOverride?

No module — it's core. But it does need the FileInfo class in the directory's AllowOverride; on hosts that lock overrides down, the line produces "not allowed here" in the error log and a 500 for every page. The 500-error guide has the full class table.

Should the 404 page be plain HTML?

Static is the right instinct — an error page that itself depends on a database or app server is a second failure mode when things are already wrong. Keep it over ~512 bytes of real content (very old browsers swapped tiny error pages for their own 'friendly' versions — mostly history, but padding costs nothing), and give it a link home plus the top destinations, not just the words "404".