What a 500 tells you, and what it doesn’t
A 500 is the server saying “something went wrong on my side and I have no more specific code for it.” RFC 9110 defines it as an unexpected condition that prevented the server from fulfilling the request. That’s the whole message. The status code carries no hint of the cause, and production servers deliberately hide the details from the public, because a stack trace in a browser is a gift to attackers.
So the useful facts are these:
- Your request reached a server, and that server produced the error. DNS, the network and TLS all worked.
- The real error message exists. It was written to a log somewhere at the exact second you saw the page. Your whole job is to find that log line.
The neighbouring codes point at different places, so make sure you’re actually looking at a 500:
| Code | What happened |
|---|---|
| 500 | The server that answered failed while handling the request, usually in application code or its configuration |
| 502 | A proxy (nginx, a load balancer, a CDN) couldn’t get a valid response from the backend behind it |
| 503 | The service is overloaded, or deliberately switched off for maintenance |
| 504 | A proxy waited for the backend and gave up |
The difference matters on a typical nginx + PHP-FPM or nginx + Node setup. If the application process is dead, nginx can’t reach it and you get a 502. If the application is running and throws an error, you get a 500. The 500 means the app is alive, and something in the request path made it fail.
If you’re just visiting the site
There’s usually nothing to fix on your side. Reload once; a single 500 can be a deploy or restart in progress. If it only happens after you log in, add something to a cart or submit a form, the server may be choking on something stored for you, and clearing that site’s cookies occasionally helps. Beyond that, tell the site owner the URL and the time it happened. The time is what lets them find the matching log line.
Step 1: confirm the status and who produced it
Start from the outside. The HTTP check shows the final status code and the full redirect chain from outside your network, or with curl:
curl -sS -o /dev/null -w '%{http_code}\n' https://www.example.com/checkout
curl -sI https://www.example.com/checkout
Look at the body and headers to see which layer wrote the error page. A page that says “nginx” at the bottom came from nginx itself. A branded page in your site’s style came from your application’s error handler. A CDN’s error page means the CDN produced the status. On Cloudflare a 5xx can also be one of its own 52x codes, which point at different problems (for example 522 is a connection timeout to the origin, not an application error).
Then narrow down the scope, because it tells you where to look before you open any log:
| Pattern | Most likely |
|---|---|
Every URL, including a plain static file like /robots.txt | Web server configuration (.htaccess, a broken directive, permissions) |
| Every dynamic page, static files fine | The application can’t start a request: config, env vars, missing dependency, database down |
| One page or one action | A bug triggered by that code path or that input |
| Only under load, or only sometimes | Resource limits, or one bad server among several |
| Started at a specific time | Whatever was deployed or changed at that time |
Step 2: find the log line
Reproduce the error while watching the log. Two terminals: one with tail -f, one with the curl above. The line that appears is your answer.
Where to look depends on the stack.
nginx: /var/log/nginx/error.log by default. With PHP-FPM behind it, PHP’s fatal errors often appear here too, in lines like FastCGI sent in stderr: "PHP message: PHP Fatal error: ...". Run nginx -T to see the effective configuration, including any custom error_log path for the site.
Apache: /var/log/apache2/error.log on Debian and Ubuntu, /var/log/httpd/error_log on RHEL-family systems. Virtual hosts can set their own ErrorLog, so check the site’s config.
PHP-FPM: the error_log setting in the php.ini that FPM loads. That is not necessarily the one the command line uses; php --ini shows the CLI’s file, not FPM’s. On Debian-style systems FPM reads /etc/php/<version>/fpm/php.ini. The FPM service log itself is in journald.
Anything running under systemd (FPM, gunicorn, a Node service):
journalctl -u myapp --since "15 min ago" --no-pager
journalctl -u php8.3-fpm -n 100 --no-pager
Containers: docker logs --tail 200 <container> or docker compose logs --tail=200 app. On Kubernetes, kubectl logs deploy/myapp, and add --previous if the container restarted, because the crash is in the previous container’s log.
Frameworks keep their own logs, separate from the web server’s:
- Laravel:
storage/logs/laravel.log. - WordPress: set
WP_DEBUGandWP_DEBUG_LOGto true andWP_DEBUG_DISPLAYto false inwp-config.php; errors go towp-content/debug.logwithout being shown to visitors. Turn it off again afterwards. - Django: with
DEBUG = False, unhandled exceptions go to the logging configuration (and toADMINSby email, if mail is configured). If you see nothing, your logging config isn’t sendingdjango.requestanywhere. - Rails:
log/production.log. - Node/Express, Spring Boot and most Go services: standard output, which means journald or the container log.
Shared hosting: look for an “Errors” or “Error log” page in the control panel. On many cPanel hosts PHP also writes a file named error_log into the directory of the script that failed.
If you find nothing anywhere, the error is being swallowed. Logging is off, or it’s going to a file you haven’t found. Fix that first; guessing at 500s without a log is slow.
The usual causes
A broken .htaccess or a directive from a missing module
Apache reads .htaccess on every request, and a single invalid line makes every request in that directory fail with 500. apachectl configtest doesn’t check .htaccess files, so a clean configtest proves nothing here. The error log names the file and the line:
/var/www/html/.htaccess: Invalid command 'RewriteEngine', perhaps misspelled or defined by a module not included in the server configuration
That one means mod_rewrite isn’t loaded (a2enmod rewrite and a reload on Debian/Ubuntu). A very common variant is Invalid command 'php_value': php_value and php_flag only work with mod_php, so after a host switches to PHP-FPM they turn into 500s. Move those settings into .user.ini or the FPM pool config.
The application can’t write where it needs to
Static pages work, every dynamic page fails. The app tries to write a session, a cache file or its own log and gets “Permission denied”. Laravel’s storage/ and bootstrap/cache/, upload directories and session directories are the usual places. This happens a lot after a deploy run as root or as a different user.
The fix is ownership, not chmod 777: make those directories owned by, or writable by, the user the application runs as (ps -o user= -p <pid> tells you who that is).
Some shared hosts that run PHP as your own user refuse to execute scripts that are writable by group or others, and answer with a 500. If a script suddenly fails after a chmod -R 777, that’s the reason; files should be 644 and directories 755.
Missing environment variables or config after a deploy
The code expects a database URL, an API key or an app secret that isn’t there. Laravel without APP_KEY fails every request with “No application encryption key has been specified.” A container started without the secret it needs throws on the first request that uses it.
Two traps catch people repeatedly. A .env file that existed on the old server isn’t in git and wasn’t copied to the new one. And a variable you exported in your shell doesn’t exist for a systemd service: services don’t inherit your login environment, so set it with Environment= or EnvironmentFile= in the unit file and restart the service.
Memory exhausted
PHP’s message is unambiguous:
PHP Fatal error: Allowed memory size of 134217728 bytes exhausted (tried to allocate 20480 bytes)
134217728 bytes is 128 MB, PHP’s default memory_limit. Raising the limit is fine for a legitimately heavy page (a large export, image processing). If an ordinary page suddenly needs more, look for the loop or query that loads everything into memory instead.
If the operating system ran out of memory instead, the kernel’s OOM killer ends a process, and what visitors see depends on what it killed: a killed worker behind nginx usually shows as 502 rather than 500. Check with:
journalctl -k | grep -i -E 'out of memory|killed process'
The database or another dependency is unavailable
The app is running but can’t reach its database, cache or an internal API, and the framework turns the exception into a 500. Typical log lines are connection refused, authentication failed after a password rotation, or MySQL’s “Too many connections” under load. Check that the dependency is up from the application server itself, not from your laptop.
A deploy that’s half there
New code with old dependencies: a missing vendor/ or node_modules, a class or module that isn’t found, a database migration that wasn’t run so the code queries a column that doesn’t exist. A runtime upgrade (a new PHP or Node major version) can do the same with deprecated functions that are now removed. If the 500s started at a deploy, roll back first and debug second.
Disk full
Sessions, uploads, caches and logs all need space. When the disk is full, writes fail and the app throws. Check both space and inodes, since millions of small session or cache files can exhaust inodes while df -h still shows free space:
df -h; df -i
nginx’s own 500: an internal redirect cycle
Sometimes nginx itself produces the 500. The classic is an SPA fallback pointing at a file that doesn’t exist:
rewrite or internal redirection cycle while internally redirecting to "/index.html"
With try_files $uri $uri/ /index.html; and no index.html in root (wrong build directory, failed build), nginx keeps redirecting internally and gives up with a 500. Check that root points at the actual build output.
Intermittent 500s
If only some requests fail, find out what’s different about them.
Behind a load balancer, one of several servers can be broken while the others work. Hit each backend directly; curl --resolve www.example.com:443:10.0.0.12 https://www.example.com/ sends the request to one specific server with the right hostname. A server that missed the last deploy or has a full disk shows up immediately.
Under load, look at connection pools, worker counts, memory and file descriptors around the timestamps of the failures. And for errors on specific pages, the log usually shows the input that triggered it: an unexpected character in a form field, a product with a missing price, a user with no profile row.
Don’t turn on debug in production
It’s tempting to flip APP_DEBUG=true or DEBUG = True to see the error in the browser. On a public site, everyone who hits an error sees the same page, and framework debug pages can include environment variables, credentials, full file paths and SQL. Read the log instead, or reproduce on a staging copy with debug on.
Making the next one faster
- Send exceptions to an error tracker, so you get the stack trace and the request that caused it without logging into a server.
- Add a request ID to every log line and show it on the error page; then “the error said ID abc123” leads straight to the log entry.
- Keep your custom error page returning status 500. A friendly page served with status 200 hides the failure from monitoring and from search engines.
After the fix, run the HTTP check again from outside to confirm the final status is 200, and check a few other URLs on the same site, not just the one that was reported.
Related errors
- 502 Bad Gateway: the proxy couldn’t get a valid answer from the app at all.
- 503 Service Unavailable: overloaded or in maintenance.
- 504 Gateway Timeout: the app took too long to answer.
Check your own domain now
Free, no sign-up. Runs the exact check this guide describes and shows what to fix.