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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
-g and matching on fields💌 Get notified on new features and updates