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;
}
}
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
| 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
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)