Troubleshooting

404 Not Found on your own server: find the path nginx or Apache really looked for

404 Not Found means the server answered but found nothing at that path. On your own server a wrong root, alias, try_files or proxy_pass is the usual cause.

Published ~10 min read

Short answer: 404 Not Found means the server is up and answered, but has nothing at that path. On someone else’s site that is a dead link or a typo. On your own server it almost always means the server looks for the file in a different place than you think: a wrong root or alias, a missing fallback for a single-page app or a PHP framework, a proxy_pass that rewrites the path, or the request landing in another server block. The log names the exact path it tried.

Debian · Ubuntu · first two checks
# Who answers, and with what? (from any machine)
curl -sI https://example.com/missing-page

# Which file did nginx try? (on the server)
sudo tail -n 20 /var/log/nginx/error.log

Whose 404 is it?

Before touching config, find out which program produced the 404. The page body and the server: header in the curl output tell you:

What you seeWho sent itWhere to look
White page, 404 Not Found, footer nginx/1.22.1 (or just nginx)nginx found no file and no rule for the pathThe nginx error log, then causes 1, 4 and 5
Plain text File not found.PHP-FPM: nginx passed a .php path that does not existCause 3
Your app's own styled error pageThe app: the request arrived, but no route matchesCause 4 or the app’s routes
Not Found with Apache/2.4.x Server at …ApacheApache section
A 404 only on reload of a deep linkThe web server, for a client-side routeCause 2

To see what the rest of the world gets, run the URL through our HTTP header checker: it requests the page from our server in Amsterdam and shows the status code, every redirect on the way and the response headers, including server. A 404 there but a page in your browser usually means DNS, a cache or a hosts-file entry sends you somewhere else.

What do the nginx logs say?

For a plain static file, nginx writes one line per missing file into /var/log/nginx/error.log:

Representative error log line
2026/10/18 09:14:02 [error] 2154#2154: *57 open() "/var/www/example.com/about.html" failed (2: No such file or directory), client: 198.51.100.23, server: example.com, request: "GET /about.html HTTP/1.1", host: "example.com"

The quoted path is the full filesystem path nginx built from root plus the URI. Compare it with ls -l on the real file: a doubled directory, a missing public/, a different case or the wrong site’s directory is the answer. server: names the server block that handled the request.

The access log tells you which URLs fail and who links to them. In the default combined format, field 9 is the status and field 7 the path:

Debian · Ubuntu · most frequent 404s
sudo awk '$9 == 404 {print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

Replace the awk program with $9 == 404 && $7 == "/about.html" {print $11} to list the referring pages of one broken URL. The URLs Google tried are in Search Console’s Pages report under Not found (404). A 404 is not always a fault: bots probing for /wp-login.php or /.env on a site without them should get exactly that.

Cause 1: root and alias build a different path than you think

root appends the whole URI to the directory. alias replaces the part of the URI that matched the location. Mixing them up is the classic static-file 404:

nginx · root and alias
# root: URI appended -> /static/app.css = /var/www/app/static/app.css
location /static/ {
    root /var/www/app;
}

# alias: prefix replaced -> /static/app.css = /var/www/app/assets/app.css
location /static/ {
    alias /var/www/app/assets/;
}

# wrong: /static/app.css = /var/www/app/static/static/app.css
location /static/ {
    root /var/www/app/static;
}

These are three alternatives; one server block takes only one of them. With alias, end the location and the alias with a slash, or neither. With location /static/ and alias /var/www/app/assets, the file becomes /var/www/app/assetsapp.css and you get a 404. The reverse, location /static with alias /var/www/app/assets/, works but lets /static../ climb one directory up, a known path-traversal mistake. When the last directory of the alias equals the location, use root instead, as the nginx documentation recommends.

Keep root at server level rather than inside location /. A location without its own root falls back to nginx’s compiled default, which on Debian and Ubuntu is /usr/share/nginx/html, so a regex location such as location ~ \.php$ quietly looks in the wrong directory.

Cause 2: 404 on refresh in a React, Vue or Angular app

Single-page apps route in the browser. Clicking from / to /dashboard never asks the server, but reloading /dashboard or opening it from a bookmark does, and there is no dashboard file on disk. The server has to answer every unknown path with index.html and let the app route:

nginx · single-page app
server {
    listen 80;
    listen [::]:80;
    server_name app.example.com;
    root /var/www/app/dist;
    index index.html;

    # Real files first; anything else is a client-side route
    location / {
        try_files $uri $uri/ /index.html;
    }

    # A missing script or stylesheet stays a real 404, not index.html
    location /assets/ {
        try_files $uri =404;
    }
}

The second location matters more than it looks. Without it, a missing chunk after a deploy is answered with index.html, and the browser reports a MIME-type error instead of a clear 404. Vite builds into dist/assets/; adjust the path to your build. If index.html itself is missing, nginx tries to redirect to it again and again, gives up after 10 internal redirects, and logs rewrite or internal redirection cycle with a 500 Internal Server Error.

Cause 3: a PHP app without its front controller

Laravel, Symfony, WordPress with pretty permalinks and most PHP frameworks route every URL through one index.php. nginx needs to know that, and the root must be the directory that contains index.php. For Laravel and Symfony that is public/, not the project directory:

nginx · PHP front controller (Debian 12)
server {
    listen 80;
    listen [::]:80;
    server_name example.com;

    # root at server level, so the PHP location below sees it too
    root /var/www/example.com/public;
    index index.php index.html;

    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php8.2-fpm.sock;
    }
}

The socket name follows the PHP version: php8.2-fpm.sock on Debian 12, php8.3-fpm.sock on Ubuntu 24.04, php8.4-fpm.sock on Debian 13. ls /run/php/ shows yours. Debian’s snippets/fastcgi-php.conf already contains try_files $fastcgi_script_name =404;, so a request for a PHP file that does not exist gets nginx’s 404 instead of reaching PHP.

If you get the plain-text File not found. instead, nginx did pass the request on and PHP-FPM could not find the script it was told to run. The error log then shows FastCGI sent in stderr: "Primary script unknown". The path in SCRIPT_FILENAME is wrong: usually a hand-written PHP location without Debian’s snippet, in a server whose root only exists inside location /, or a pool that runs in a chroot or a container with a different directory layout.

Cause 4: proxy_pass changes the path

When nginx sits in front of a Node, Python or Go app, the app’s 404 often comes from one character. If proxy_pass ends with a URI, even just /, nginx replaces the matched location prefix with it. Without a URI, the path goes through unchanged:

nginx · two different proxy_pass forms
# /api/users reaches the app as /api/users
location /api/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_set_header Host $host;
}

# /api/users reaches the app as /users
location /api/ {
    proxy_pass http://127.0.0.1:3000/;
    proxy_set_header Host $host;
}

Pick the form that matches the app’s routes, and confirm by asking the app directly on the server: curl -i http://127.0.0.1:3000/api/users against curl -i http://127.0.0.1:3000/users. Also keep proxy_set_header Host $host;: by default nginx sends the address from proxy_pass as the Host header, and a backend with name-based virtual hosts (another nginx, Apache, a multi-site app) answers an unknown host with its own 404. How the proxy and the app share that work is laid out in what a reverse proxy does.

Cause 5: another server block answers

nginx picks the server block by IP, port and the Host header. If no server_name matches, the default server for that address and port answers. On Debian and Ubuntu that is the stock site with root /var/www/html, which has none of your files. It uses try_files, so its 404s leave no line in the error log (with the debug log from above, the paths start with /var/www/html). Usual reasons:

  • The site file is in sites-available but not linked into sites-enabled, or nginx.conf does not include that directory.
  • A typo in server_name, or only example.com is listed while visitors use www.example.com.
  • IPv6: the domain has an AAAA record, but your block only says listen 80;. IPv6 visitors arrive on [::]:80, where only the default site listens, and get its 404. The site works for you on IPv4 and fails for others. Add listen [::]:80; (and listen [::]:443 ssl;) next to the IPv4 lines.
Debian · Ubuntu · which block serves the name?
# Every listen, server_name and root nginx actually loaded
sudo nginx -T 2>/dev/null | grep -nE '^\s*(listen|server_name|root)'

# Ask nginx on the server for the name, over IPv4 and IPv6
curl -s -H 'Host: example.com' http://127.0.0.1/ | grep -i '<title>'
curl -s -H 'Host: example.com' 'http://[::1]/' | grep -i '<title>'

Welcome to nginx! is the stock default site, so the request missed your block. Your own page title, or a 301 page if the block redirects to HTTPS, means your block answered. Different answers over IPv4 and IPv6 point at the listen lines.

Also rule out DNS: if the name points at another machine, that machine’s 404 is what you see. Compare dig +short A example.com and dig +short AAAA example.com with your server’s addresses.

Cause 6: case, spelling and the upload

Linux file systems are case-sensitive; Windows and macOS by default are not. A link to /images/Logo.png works on your laptop and fails on the server when the file is called logo.png. Search case-insensitively to find the real name, and check that the deploy actually copied the file:

Debian · Ubuntu
find /var/www/example.com -iname 'logo.png'

A file nginx is not allowed to read is a different error: that is a 403 Forbidden with (13: Permission denied) in the log. Under try_files there is a twist: nginx logs stat() … failed (13: Permission denied) at level crit, skips that candidate and can end in a 404 anyway, so read the log before you conclude the file is missing.

404 on Apache: .htaccess ignored and a quiet error log

Apache 2.4 logs a missing file as AH00128: File does not exist at level info, while Debian and Ubuntu set LogLevel warn. So the error log usually stays empty and the 404 is only in the access log. For permalinks and frameworks that rely on .htaccess, two defaults cause most 404s:

  • AllowOverride None. It is Apache’s default, and Debian’s apache2.conf sets it for /var/www/. Apache then never reads .htaccess, so the rewrite rules in it do nothing.
  • mod_rewrite not enabled. WordPress wraps its rules in <IfModule mod_rewrite.c>, so without the module they are skipped silently and every permalink returns 404. A .htaccess without that wrapper fails with a 500 instead.
Apache · in the site's VirtualHost
<Directory /var/www/example.com/public>
    AllowOverride All
</Directory>
Debian · Ubuntu
sudo a2enmod rewrite
sudo apache2ctl configtest && sudo systemctl restart apache2

a2enmod asks for a restart, not a reload, after it enables a module. If WordPress permalinks still return 404, open Settings → Permalinks and click Save Changes: WordPress then writes its rules into .htaccess again, or shows them for you to paste when it may not write the file.

A front controller does not need mod_rewrite at all: FallbackResource /index.php inside the Directory block sends every request that matches no file to index.php.

A custom 404 page that still returns 404

A friendly 404 page is fine, as long as the status stays 404. In nginx:

nginx · custom 404 page
error_page 404 /404.html;

location = /404.html {
    root /var/www/errors;
    internal;
}

internal makes /404.html reachable only as an error page, not as a URL of its own. Two things turn it into a soft 404 that search engines report and slowly stop trusting: error_page 404 =200 …, which changes the status to 200, and redirecting every missing URL to the home page. For an app behind proxy_pass, add proxy_intercept_errors on; to that location if nginx should show this page for the app’s 404s too. Then check the status, not the page:

Linux · macOS
curl -s -o /dev/null -w '%{http_code}\n' https://example.com/this-does-not-exist

It must print 404.

404, 410 or a redirect?

HTTP defines 404 as “nothing here” without saying for how long, and 410 Gone as removed on purpose and likely for good. Google says it treats all 4xx codes except 429 the same way: the URL drops out of the index and gets crawled less often. So 410 is the honest status for content you deleted deliberately, while 404 is right for everything else, and neither hurts the rest of your site.

SituationSendWhy
The content moved to a new URL301 to that exact URLVisitors and links follow, rankings move with it
Removed on purpose, no replacement410States the removal is intentional and permanent
Never existed, typo, unknown404The correct default
Removed, so redirect to the home pageAvoidSearch engines may treat it as a soft 404
nginx · 410 for removed URLs, 301 for moved ones
# http {} level: list removed URLs once
map $uri $gone {
    default          0;
    /old-pricing     1;
    ~^/2019-promo/   1;
}

server {
    # ... your existing listen, server_name, root ...

    if ($gone) {
        return 410;
    }

    location = /old-contact {
        return 301 /contact;
    }
}

Test every change with sudo nginx -t before sudo systemctl reload nginx; a failed test leaves the running config untouched. A 502 Bad Gateway after a change means nginx no longer reaches the backend, a different problem from a 404.

If your new redirects send visitors in circles instead, our guide to ERR_TOO_MANY_REDIRECTS shows how to trace the chain.

All of this assumes you can read the logs and edit the config, which a root server gives you. On a VMHeaven Ubuntu VPS or Debian VPS nginx is one apt install away, and the browser VNC console in the panel still reaches the machine if a firewall change locks you out of SSH.

Frequently asked

Why do I get a 404 when I refresh a page in my React or Vue app?

The app routes in the browser, but a reload asks the server for that path, and no such file exists on disk. Tell the web server to answer unknown paths with index.html; in nginx that is 'try_files $uri $uri/ /index.html;' in the location / block.

Do 404 errors hurt SEO?

Not for the rest of your site. Google drops URLs that return 404 from its index and crawls them less often, which is correct for pages that do not exist. What hurts is a soft 404, an error page sent with status 200, and broken links on pages that still matter.

What is the difference between 404 and 410?

404 Not Found says there is nothing at the URL without saying for how long. 410 Gone says the resource was removed on purpose and will not come back. Use 410 for content you deleted deliberately and a 301 redirect for content that moved to a new URL.

Related articles

All troubleshooting