How to check Nginx error logs

Nginx is returning 502 and the error log looks empty

It probably is not empty. It is probably in a different file than you think, at a level that hides what you need.

Nginx writes two logs and people conflate them. The access log records requests. The error log records everything that went wrong. A 502 puts a line in both, and only one of them says why.

Find the file

The default is /var/log/nginx/error.log. Trust the config over the default:

grep -r error_log /etc/nginx/

Nginx allows an error_log per http, server and location block, so a vhost can redirect its errors somewhere you are not looking. That grep finds all of them.

If the config sets none, nginx uses the path it was compiled with:

nginx -V 2>&1 | tr ' ' '\n' | grep error-log-path

And confirm the config that is loaded is the config you are reading:

nginx -t

That prints the config file in use. On a box with both /etc/nginx and a hand-built nginx, this is usually where the confusion ends.

Read it

sudo tail -n 100 /var/log/nginx/error.log

Follow it live while you reproduce the problem:

sudo tail -f /var/log/nginx/error.log

A line looks like this:

2026/08/30 11:02:14 [error] 1234#1234: *5 connect() failed (111: Connection refused) while connecting to upstream, client: 192.0.2.9, server: example.com, request: "GET /api/pay HTTP/1.1", upstream: "http://127.0.0.1:3000/api/pay", host: "example.com"

Read it in pieces:

Piece Meaning
2026/08/30 11:02:14 When
[error] Severity
1234#1234 Worker process id and thread id
*5 Connection number — the same request in other lines shares it
connect() failed (111: Connection refused) What the OS said
client: Who asked
upstream: Which backend nginx tried
request: The request line

upstream: is the field worth learning. When it is present, nginx did its job and something behind it did not.

The three you will actually see

connect() failed (111: Connection refused) while connecting to upstream — your application is not listening. Nginx returns 502.

upstream timed out (110: Connection timed out) while reading response header from upstream — your application is listening and too slow. Nginx returns 504 after proxy_read_timeout, 60 seconds by default.

open() "/usr/share/nginx/html/thing" failed (2: No such file or directory) — a 404 with the resolved path in it. When the path looks wrong, your root or alias is wrong.

When the error log is genuinely quiet

Raise the level. The error_log directive takes a severity as its second argument:

error_log /var/log/nginx/error.log warn;

Levels run debug, info, notice, warn, error, crit, alert, emerg. Nginx logs the level you name and everything more severe. So error — a common default — hides every warning.

Reload to apply:

sudo nginx -t && sudo systemctl reload nginx

Drop to info while you chase something specific, then put it back. An info error log on a busy server is large.

debug needs more than a config change. It only works if nginx was built with --with-debug; check with nginx -V 2>&1 | grep -o with-debug. Without it, setting debug silently gives you info.

Two places the file is not

Startup failures. If nginx dies before it parses the config, there is no error log to write to. Those messages go to stderr and land in the journal:

journalctl -u nginx --since "10 minutes ago"

Check this whenever systemctl start nginx fails and the error log has nothing new.

Containers. The official nginx image symlinks /var/log/nginx/error.log to /dev/stderr, so the file is always empty and the lines go to Docker:

docker logs --tail 100 my-nginx

That is deliberate. It is also why tail on a mounted log directory shows nothing. Viewing Docker container logs covers the rest of that.

One server is easy. Four is not.

Behind a load balancer, a user’s failing request hit one of your web servers and you do not know which. So you tail -f all of them in separate terminals and watch.

That works. It does not scale, and it only shows you the present. The 502s from last Tuesday are in a rotated, gzipped file on a server you have since replaced.

Copy the lines somewhere central instead.

Ship the error log

Nginx writes to a file, so give the file to the journal and let the CL Agent take it from there. A systemd unit is the least fragile way:

# /etc/systemd/system/nginx-errors.service
[Unit]
Description=Ship the nginx error log to the journal
After=nginx.service

[Service]
ExecStart=/usr/bin/tail -F -n 0 /var/log/nginx/error.log
SyslogIdentifier=nginx-error
Restart=always

[Install]
WantedBy=multi-user.target
sudo systemctl enable --now nginx-errors

tail -F follows the path rather than the file handle, so logrotate does not silently end the stream. -n 0 starts at the end instead of replaying the whole file.

Then install the agent and point it at your server:

URL = "https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"

Full setup in sending Nginx logs to a central server, which also covers the access log and a JSON log_format worth using.

Search every web server at once

The agent runs journalctl -o json, so each entry arrives as an object and the nginx text sits in MESSAGE:

SELECT json_extract(msg, '$._HOSTNAME') AS host,
       json_extract(msg, '$.MESSAGE')   AS line,
       timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'nginx-error'
ORDER BY timestamp DESC
LIMIT 100;

json_valid(msg) goes first and stays first. One plain-text line in the source is enough to make json_extract raise malformed JSON, and SQLite short-circuits AND in a WHERE clause so the guard protects what follows it. It does not protect the SELECT list, so the guard has to remove the bad rows rather than merely precede the extracts.

Upstream failures only:

SELECT json_extract(msg, '$._HOSTNAME') AS host,
       json_extract(msg, '$.MESSAGE')   AS line,
       timestamp
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'nginx-error'
  AND json_extract(msg, '$.MESSAGE') LIKE '%upstream%'
ORDER BY timestamp DESC
LIMIT 100;

Which host is producing the most errors:

SELECT json_extract(msg, '$._HOSTNAME') AS host,
       COUNT(*) AS errors
FROM logs
WHERE json_valid(msg)
  AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'nginx-error'
  AND json_extract(msg, '$.MESSAGE') LIKE '%[error]%'
  AND timestamp > CAST(strftime('%s', 'now', '-1 day') AS INTEGER)
GROUP BY host
ORDER BY errors DESC;

One host with all the errors is a sick host. Errors spread evenly is a sick backend.

Get told about the next one

An alert rule fires when its query returns any row. The engine checks whether the result is empty and reads nothing further, so SELECT 1 ... LIMIT 1 is the correct shape.

Fire when upstream timeouts pass 20 in ten minutes:

SELECT 1 WHERE (
  SELECT COUNT(*) FROM logs
  WHERE json_valid(msg)
    AND json_extract(msg, '$.SYSLOG_IDENTIFIER') = 'nginx-error'
    AND json_extract(msg, '$.MESSAGE') LIKE '%upstream timed out%'
    AND timestamp > CAST(strftime('%s', 'now', '-10 minutes') AS INTEGER)
) >= 20 LIMIT 1;

Use a scalar subquery for the threshold. HAVING COUNT(*) >= 20 without a GROUP BY raises HAVING clause on a non-aggregate query, and an alert rule that errors does not warn you — it just never fires.

Rules run every five minutes and deliver within a minute. More in alerting on errors in your logs.

Keep the file from eating the disk

Nginx does not rotate its own logs. The package ships a logrotate config at /etc/logrotate.d/nginx; a hand-built nginx has none, and the error log grows until the disk fills.

The postrotate step matters. Nginx holds the old file open after a rename, so logrotate has to tell it to reopen:

/var/log/nginx/*.log {
    daily
    rotate 14
    compress
    delaycompress
    missingok
    notifempty
    postrotate
        [ -f /var/run/nginx.pid ] && kill -USR1 $(cat /var/run/nginx.pid)
    endscript
}

Without that kill -USR1, nginx keeps writing to the rotated file and the new error.log stays empty at zero bytes — which is the other way this page’s opening symptom happens.

Where to go next

💌 Get notified on new features and updates

Only sent when a new version is released. Nothing else.