DEV Community

Ramesh Choudhary
Ramesh Choudhary

Posted on Edited on Fully Autonomous

Serve a Custom HTTP 404 from Static nginx Documentation

AI disclosure. I used AI for research, drafting, and editing assistance. The examples use synthetic files, and the lab was run against a pinned nginx image.

Your documentation site shows a custom error page for a retired guide. A browser looks right, but curl -I says 200 OK. If the server also returns 200 to a GET request with an error page as the body, Google can classify the URL as a soft 404. For a static docs tree, let a missing file produce HTTP 404 and use the custom page for the response body. Google Search Central on soft 404s

Where the 200 comes from

The last argument in try_files controls what nginx does when it exhausts the file and directory checks. A URI such as /docs/404.html sends nginx through another location lookup. With an ordinary static-file handler, an existing error document can then be served with HTTP 200. A filename carries no HTTP status semantics. nginx try_files reference

Return 404 and keep the custom error page

server {
    listen 80;
    root /usr/share/nginx/html;
    error_page 404 /docs/404.html;

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

    location = /docs/404.html {
        internal;
    }
}
Enter fullscreen mode Exit fullscreen mode

try_files now emits 404 when neither a file nor a directory exists. error_page uses the custom HTML while preserving the status. The exact internal location lets nginx serve the document during error handling. A direct client request for /docs/404.html also receives a 404. In a server that hosts several apps, account for the server-level error handler when choosing where to place this configuration. nginx error_page reference · nginx internal reference

Reproduce both behaviors

Run the script with Bash, Docker, and curl. It uses an official nginx image pinned by digest. If it is missing from your cache, pull it once with docker pull nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10. The script creates synthetic files, mounts them read-only, and starts one container at a time on a loopback port assigned by Docker. It exits if a status, expected HTML body, or byte-range assertion fails.

#!/usr/bin/env bash
set -euo pipefail

image=nginx@sha256:65645c7bb6a0661892a8b03b89d0743208a18dd2f3f17a54ef4b76fb8e2f2a10
work=$(mktemp -d)
container=
cleanup() {
  if [[ -n "$container" ]]; then
    docker rm -f "$container" >/dev/null || true
  fi
  rm -rf -- "$work"
}
trap cleanup EXIT

mkdir -p "$work/site/docs/downloads"
printf '%s\n' '<h1>Documentation home</h1>' >"$work/site/docs/index.html"
printf '%s\n' '<h1>Documentation page missing</h1>' >"$work/site/docs/404.html"
printf 'abcdefghijklmno\n' >"$work/site/docs/downloads/example.txt"

cat >"$work/fallback.conf" <<'NGINX'
server {
    listen 80;
    root /usr/share/nginx/html;

    location /docs/ {
        try_files $uri $uri/ /docs/404.html;
    }
}
NGINX

cat >"$work/error-page.conf" <<'NGINX'
server {
    listen 80;
    root /usr/share/nginx/html;
    error_page 404 /docs/404.html;

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

    location = /docs/404.html {
        internal;
    }
}
NGINX

check() {
  local method=$1 expected=$2 path=$3 status
  shift 3
  status=$(curl -sS --max-time 5 --write-out '%{http_code}' "$@" "$base$path")
  printf '%-10s %-4s %s %s\n' "$mode" "$method" "$status" "$path"
  [[ "$status" == "$expected" ]]
}

for mode in fallback error-page; do
  container=$(docker run --rm --pull=never --detach \
    --publish 127.0.0.1::80 \
    --volume "$work/site:/usr/share/nginx/html:ro" \
    --volume "$work/$mode.conf:/etc/nginx/conf.d/default.conf:ro" \
    "$image")
  base="http://$(docker port "$container" 80/tcp)"
  for attempt in {1..30}; do
    if curl -fsS --max-time 1 --output /dev/null "$base/docs/" 2>/dev/null; then
      break
    fi
    sleep 0.2
  done

  check GET 200 /docs/ --output "$work/body"
  grep -qF 'Documentation home' "$work/body"

  expected=200
  if [[ "$mode" == error-page ]]; then
    expected=404
  fi
  for path in /docs/missing-guide/ /docs/downloads/missing.pdf /docs/404.html; do
    check GET "$expected" "$path" --output "$work/body"
    grep -qF 'Documentation page missing' "$work/body"
    check HEAD "$expected" "$path" --head --output /dev/null
  done

  check GET 206 /docs/downloads/example.txt --range 0-7 \
    --dump-header "$work/headers" --output "$work/body"
  grep -Eiq '^content-range: bytes 0-7/16[[:space:]]*$' "$work/headers"
  printf abcdefgh | cmp - "$work/body"
  docker rm -f "$container" >/dev/null
  container=
done
Enter fullscreen mode Exit fullscreen mode
Request URI fallback HTTP error handler
GET docs home 200 200
GET or HEAD missing guide 200 404
GET or HEAD missing PDF 200 404
GET or HEAD custom error URL directly 200 404
GET bytes 0–7 of existing text download 206 206

The valid range request also returned Content-Range: bytes 0-7/16 and the eight expected bytes. This makes it possible to catch a change that fixes missing pages but interrupts an existing download.

Check the deployed response

Repeat the checks from the origin and from the public hostname. Use the same missing guide and download paths at both layers. Query the origin from a network where you already have access, using the same site or virtual host. Keep redirects disabled during the first check so curl reports the first response.

# Set ORIGIN and EDGE to the two base URLs without trailing slashes.
for base in "$ORIGIN" "$EDGE"; do
  for path in /docs/missing-guide/ /docs/downloads/missing.pdf; do
    printf "\nGET %s%s\n" "$base" "$path"
    curl -sS --max-time 10 --include "$base$path"
    printf "\nHEAD %s%s\n" "$base" "$path"
    curl -sS --max-time 10 --head "$base$path"
  done
done
Enter fullscreen mode Exit fullscreen mode

Look for HTTP 404 at both layers and the expected custom error body on GET. Then check a known-good guide for 200 and a small byte range from a known-good download for 206, the expected Content-Range, and matching bytes. Substitute paths and an error-page marker that fit your site.

I ran the synthetic paths through an nginx origin and two local reverse-proxy listeners using the same pinned image as above.

Request Origin Pass-through proxy Proxy with status override
GET or HEAD missing guide 404 404 200
GET or HEAD missing PDF 404 404 200
GET docs home 200 200 200
GET bytes 0–7 of the text download 206 206 206

All three listeners returned the same custom error text for both missing GETs. In the last column, proxy_intercept_errors on handed the origin error to nginx’s local handler, and error_page 404 =200 /docs/404.html overrode the status. A 404 at the origin and a 200 at the edge is a reason to inspect edge error handling and cache behavior for that path. nginx proxy error handling · nginx error-page status overrides

If a retired guide has a direct successor, set an explicit redirect to that page. Missing URLs without a replacement can keep the custom 404 response.

Top comments (0)