Short answer. By default nginx writes to /var/log/nginx/access.log and /var/log/nginx/error.log. The real paths come from the access_log and error_log directives — find them with nginx -T | grep -E 'access_log|error_log'. In a container the logs go to stdout and are read with docker logs. Rotation is handled by logrotate, and it only works together with a USR1 signal to nginx.
An nginx log is not just a file that something drips into. It is the only authoritative record of what the server actually returned, how long it took, and with which status code. Add three or four variables to the format and the log stops being an archive and becomes a diagnostic tool: slow upstreams, bots, 5xx spikes and broken links all become visible. Below: where the files live, how the format is built, which one-liners actually work, and how to configure rotation so the disk does not fill up within a week.
Where nginx logs live and how to find the real path
The default location
Distribution packages for Debian, Ubuntu and RHEL-like systems, as well as the official builds from nginx.org, all use the same directory:
/var/log/nginx/access.log # every served HTTP request
/var/log/nginx/error.log # worker errors, upstream failures, permission and TLS problems
The directory usually belongs to root, while the files belong to the user the workers run as (www-data, nginx) with group adm or root. If you read logs as a non-root user, add yourself to that group instead of relaxing permissions to 0644 — access.log contains visitor IP addresses.
Why a given site logs somewhere else
The access_log and error_log directives are valid at the http, server and location levels, and an inner level fully overrides the outer one. On a multi-site server there is almost always a separate file per virtual host, which leaves the global /var/log/nginx/access.log empty. Do not guess — ask nginx:
# full dump of the assembled configuration, all includes expanded
nginx -T | grep -nE 'access_log|error_log'
# same, but you also see which file each directive came from
nginx -T | grep -nE '^# configuration file|access_log|error_log'
# which files the running master actually has open right now
sudo ls -l /proc/$(pgrep -o nginx)/fd | grep -i log
nginx -T validates the syntax and then prints the effective configuration with all include files expanded and a comment showing the origin of every block. On a server you are seeing for the first time, this is the only reliable method. Configuration structure and directive precedence are covered in the nginx configuration guide.
No logs at all: three reasons
Logging is disabled. Somewhere in the config there is access_log off; — often on static assets or a health-check location, sometimes at the server level "for performance". The same nginx -T reveals it.
Nginx runs in a container. In the official image /var/log/nginx/access.log is a symlink to /dev/stdout and error.log a symlink to /dev/stderr. Inside the container the file is empty by design; the Docker logging driver collects the streams:
docker logs --since 1h --tail 200 nginx
docker logs -f nginx 2>/dev/null # access only (stdout)
docker logs -f nginx 1>/dev/null # error only (stderr)
# where the json-file log physically lives
docker inspect --format '{{.LogPath}}' nginx
Permissions or SELinux. If nginx cannot open a log file for writing it says so at startup and in journalctl -u nginx. On SELinux systems the log directory needs the httpd_log_t context, otherwise writes are denied silently for the application and very loudly in audit.log.

Before you look for the cause of a problem in the log, make sure you are reading the right log. Half of the mysterious "nothing in the logs but the site returns 500" cases are simply someone reading the global access.log while per-server logging is enabled.
The combined format: field by field
The default format is called combined and is built into nginx, so you never declare it yourself. Its definition:
log_format combined '$remote_addr - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
A real line:
203.0.113.42 - - [06/Aug/2026:12:41:07 +0300] "GET /articles/nginx-logs-guide HTTP/1.1" 200 18342 "https://example.com/articles" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
Broken down:
203.0.113.42—$remote_addr, whoever opened the TCP connection. Behind a load balancer or a CDN this is the proxy, not the visitor.- the first
-— historically the identd user, effectively always empty. - the second
-— an empty$remote_user, the basic-auth login when present. [06/Aug/2026:12:41:07 +0300]—$time_local, the moment the request finished, in the server's local time zone. A slow request lands in the log later than it started."GET /articles/nginx-logs-guide HTTP/1.1"—$request: method, URI including the query string, and protocol version, exactly as the client sent them.200—$status, the final response code. What each one means is in the HTTP status code reference.18342—$body_bytes_sent: body only, headers excluded. For real bandwidth accounting use$bytes_sent."https://example.com/articles"—$http_referer.- the last field —
$http_user_agent, verbatim.
The critical limitation of combined: no response time, no host name, no upstream address. Such a log cannot answer "why was the site slow at 14:20" — it only shows that requests happened and the codes were 200.
A custom log_format that actually helps you debug
Five variables change the value of the log completely. All of them are listed in the nginx variable index:
$request_time— total processing time in seconds with millisecond resolution, from the first byte read to the last byte written to the socket. It includes a slow client, so a large value does not automatically mean a slow backend.$upstream_response_time— how long the upstream took. The gap between this and$request_timeis network to the client, buffering, and nginx itself.$upstream_connect_timeand$upstream_header_time— where exactly the time went: establishing the connection, waiting for the first header byte, or streaming the body.$upstream_addr— which backend served the request. On retries you get several addresses separated by commas, an instant signal that the first upstream failed.$host— which virtual host served the request. Mandatory when several sites share one file.$http_x_forwarded_for— the proxy chain. Needed untilreal_ipis configured, and useful as a fallback afterwards.
A format worth having on every proxying server:
# inside the http {} block
log_format ext '$remote_addr "$http_x_forwarded_for" - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" '
'host=$host rt=$request_time uct=$upstream_connect_time '
'uht=$upstream_header_time urt=$upstream_response_time '
'ua=$upstream_addr us=$upstream_status cache=$upstream_cache_status';
access_log /var/log/nginx/access.log ext;
Named fields such as rt= and urt= are not decoration: they let you pull values by name rather than by column number, so the format can grow without breaking every script you ever wrote.
JSON instead of positional fields
If logs are shipped to a collector (Loki, Elasticsearch, ClickHouse), a positional format is an unnecessary parsing step. Current nginx versions support escape=json, which correctly escapes quotes and control characters in User-Agent and URI values:
log_format json_ext escape=json
'{'
'"time":"$time_iso8601",'
'"host":"$host",'
'"remote_addr":"$remote_addr",'
'"xff":"$http_x_forwarded_for",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"bytes_sent":$bytes_sent,'
'"request_time":$request_time,'
'"upstream_addr":"$upstream_addr",'
'"upstream_status":"$upstream_status",'
'"upstream_response_time":"$upstream_response_time",'
'"referer":"$http_referer",'
'"user_agent":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.json json_ext;
Without escape=json any User-Agent containing a quote or a backslash breaks the JSON line and the collector silently drops records. Test this against real traffic, not your own browser — the strings that break the format usually come from scanners.
Buffering and selective logging
On a busy server, writing every line synchronously is measurable disk load. A buffer with a timed flush removes almost all of it, at the cost of losing the last few seconds of log if the process dies hard:
access_log /var/log/nginx/access.log ext buffer=64k flush=5s;
# skip successful responses for static assets and health checks
map $status $loggable {
~^[23] 0;
default 1;
}
server {
location = /healthz {
access_log off;
return 200 "ok\n";
}
location ~* \.(css|js|png|jpg|jpeg|gif|webp|svg|woff2?)$ {
access_log /var/log/nginx/static.log ext if=$loggable;
}
}
Do not disable access_log for static assets entirely: that is exactly where you see which files return 404 and who is mirroring the whole site. Route static traffic to its own file instead. Other ways to reduce overhead are in the nginx performance tuning guide.

error_log: levels and what the common messages mean
The error_log directive takes a path and a level. Levels go from least to most severe, and the level you set means "this one and everything more severe":
debug— a full trace of every request, hundreds of lines each;info— informational messages, including clients closing connections;notice— normal events such as a configuration reload;warn— warnings: a buffer is too small, an upstream behaved oddly;error— the default: a request was not served correctly;crit,alert,emerg— the server is in trouble.
A practical production default is warn: it catches buffer and certificate problems before they turn into errors, and it produces very little volume.
The price of debug
debug is not "slightly more verbose". On a server handling hundreds of requests per second it can produce tens of gigabytes in minutes and measurably slow request processing. It also requires a build with --with-debug. The right approach is to scope it to a single client:
# does this build support debug output at all
nginx -V 2>&1 | tr ' ' '\n' | grep -- --with-debug
# inside the events {} block: debug for one client only
events {
debug_connection 203.0.113.42;
}
# a dedicated debug file for one site
server {
error_log /var/log/nginx/example.debug.log debug;
}
Turn debug off in the same maintenance window in which you turned it on. A forgotten debug log is the most common cause of a suddenly full disk, and the nastiest leak: it records complete request headers, cookies and tokens included.
Common messages decoded
An error_log line looks like date [level] pid#tid: *connection_number message, followed by context: client, server, request, upstream, host. The context matters more than the message itself — it tells you which site and which URL were affected.
connect() failed (111: Connection refused) while connecting to upstream— the backend is not listening on that port or socket. The application process died, or it binds a different address. The client sees 502.upstream timed out (110: Connection timed out) while reading response header— the backend accepted the connection but did not answer withinproxy_read_timeout. The client sees 504. The fix is finding the slow query, not raising the timeout.no live upstreams while connecting to upstream— every server in theupstreamblock is marked unavailable bymax_failsand is waiting outfail_timeout. A sign that backends failed together. Full walkthrough in the 502 Bad Gateway guide.open() "/var/www/site/index.html" failed (13: Permission denied)— the worker lacks permission. Check the execute bit on every parent directory, not just the file itself, and the SELinux context on RHEL-like systems.SSL_do_handshake() failed— the TLS handshake failed. With aclientcontext it is usually an old client or a scanner; with anupstreamcontext it is a certificate or SNI problem on the backend.client intended to send too large body—client_max_body_sizeexceeded, the client gets 413. The default is 1 MB, which is not enough for almost any upload form.upstream sent too big header while reading response header—proxy_buffer_sizeis too small. Long Set-Cookie headers or framework debug headers are the usual culprits.worker_connections are not enough— the per-worker connection limit was hit; raise it together withworker_rlimit_nofile.directory index of "/var/www/site/" is forbidden— no file from theindexlist exists andautoindexis off. The client gets 403.
Which log to open in which situation
| File or stream | What it holds | When to open it | How to grep it |
|---|---|---|---|
/var/log/nginx/access.log | Every served request: IP, URI, status, size, timing | 4xx/5xx spike, suspected bots, traffic analysis | awk '$9 ~ /^5/' |
/var/log/nginx/error.log | Upstream failures, permissions, TLS, buffer limits | 502, 504, 403, blank page, "the site is down" | grep -E 'upstream|denied|SSL' |
Per-server files from nginx -T | Traffic of one specific virtual host | Multi-site server, complaint about one domain | nginx -T | grep access_log |
access.log.1, *.log.*.gz | Rotated copies of previous days and weeks | Retrospective, post-incident analysis | zgrep, zcat |
/dev/stdout, /dev/stderr | The same streams, handed to the Docker logging driver | Containerised nginx, empty file in the volume | docker logs --since 1h |
journalctl -u nginx | systemd messages about start, crash and reload of the master | Nginx will not start, the config failed validation | journalctl -u nginx -n 50 --no-pager |
A dedicated error_log ... debug | Full trace of request processing | Reproducible bug, temporarily only | grep '*12345 ' by connection number |
Analysing nginx logs: one-liners that work
Everything below assumes the combined format with fixed field positions: $1 is the IP, $4 the timestamp, $7 the URI, $9 the status code and $10 the body size. If you prepend fields to the format the numbers shift — one more argument for named fields.
Who is requesting what
L=/var/log/nginx/access.log
# top 20 IPs by request count
awk '{print $1}' $L | sort | uniq -c | sort -rn | head -20
# top 20 URLs
awk '{print $7}' $L | sort | uniq -c | sort -rn | head -20
# status code distribution
awk '{print $9}' $L | sort | uniq -c | sort -rn
# top User-Agents (the field between the 6th and 7th quote)
awk -F'"' '{print $6}' $L | sort | uniq -c | sort -rn | head -20
# unique addresses in this file
awk '{print $1}' $L | sort -u | wc -l
# bandwidth per IP, in megabytes
awk '{b[$1]+=$10} END {for (i in b) printf "%8.1f MB %s\n", b[i]/1048576, i}' $L \
| sort -rn | head -20
Errors and time windows
# every 5xx since the start of the current hour
awk -v h="$(date '+%d/%b/%Y:%H')" 'index($4, h) && $9 ~ /^5/' $L
# which URLs return 404 most often
awk '$9 == 404 {print $7}' $L | sort | uniq -c | sort -rn | head -20
# request distribution by hour
awk '{print substr($4, 2, 14)}' $L | uniq -c
# status codes per URL: where exactly it is failing
awk '$9 ~ /^[45]/ {print $9, $7}' $L | sort | uniq -c | sort -rn | head -30
# a slice of the exact minute of the incident
grep '06/Aug/2026:14:20:' $L | awk '{print $9}' | sort | uniq -c
Slow requests
These require the rt= field from the extended format above. They look the field up by name rather than by position, so the format can keep growing:
# top 20 slowest requests: time, URL
awk '{ for (i=1;i<=NF;i++) if ($i ~ /^rt=/) { t=substr($i,4); print t, $7 } }' $L \
| sort -rn | head -20
# average and maximum request_time per URL
awk '{ for (i=1;i<=NF;i++) if ($i ~ /^rt=/) t=substr($i,4);
n[$7]++; s[$7]+=t; if (t>m[$7]) m[$7]=t }
END { for (u in n) printf "%6d avg=%.3f max=%.3f %s\n", n[u], s[u]/n[u], m[u], u }' $L \
| sort -k3 -rn | head -20
# how many requests took longer than a second
awk '{ for (i=1;i<=NF;i++) if ($i ~ /^rt=/ && substr($i,4)+0 > 1) c++ } END {print c+0}' $L
If slow requests are numerous and scattered across unrelated URLs, nginx is usually not the problem — move on to server load troubleshooting.
Filtering bots and reading archives
# traffic with known crawlers removed
grep -viE 'bot|crawl|spider|slurp|yandex|google|bing|ahrefs|semrush' $L \
| awk '{print $1}' | sort | uniq -c | sort -rn | head -20
# crawlers only: who is pulling how much
grep -iE 'bot|crawl|spider' $L | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head
# the same queries against rotated archives
zcat /var/log/nginx/access.log.*.gz | awk '$9 ~ /^5/ {print $7}' | sort | uniq -c | sort -rn
zgrep -h '203.0.113.42' /var/log/nginx/access.log.*.gz | head
# live tail with a filter
tail -f $L | grep --line-buffered -E ' (50[0-9]|429) '
When one-liners stop being enough, install goaccess — a terminal log analyser that reads the combined format and can emit an interactive HTML report broken down by IP, URL, status code, crawler and geography. It also runs in real time on top of tail.

Real client IP behind a proxy or CDN
As soon as a load balancer, a CDN or another nginx sits in front, $remote_addr stops being the visitor's address and becomes the proxy's. The consequences are unpleasant and not always obvious: analytics shows two or three unique IPs, limit_req and limit_conn apply their quotas to the proxy as a whole, and fail2ban bans your own front end instead of the attacker.
The real_ip module fixes this. First confirm it is compiled in:
nginx -V 2>&1 | tr ' ' '\n' | grep realip
# expected: --with-http_realip_module
# inside the http {} block
set_real_ip_from 10.0.0.0/8; # only the networks your proxies actually use
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# for a CDN that sends its own header:
# real_ip_header CF-Connecting-IP;
real_ip_recursive on makes nginx walk the X-Forwarded-For chain from right to left and take the first address that is not in a trusted network. Without it nginx takes the last address in the list — which is the proxy again if there are two of them.
Never writeset_real_ip_from 0.0.0.0/0.X-Forwarded-Foris an ordinary header that anyone can forge. Trusting everyone hands an attacker the ability to write any IP into your logs, bypass rate limits and get innocent addresses banned.
The header mechanics, the order of addresses in the chain and the standardised Forwarded alternative are covered in the X-Forwarded-For guide. Directive details are in the ngx_http_realip_module documentation.
Once configured, keep both values in the format: $remote_addr now shows the client, while $http_x_forwarded_for preserves the original chain — invaluable when you debug an incident across several proxy layers.
Rotating nginx logs: logrotate and the USR1 signal
Nginx cannot rotate its own logs. It opens the file at startup and keeps writing through that file descriptor until something asks it to reopen. Rotation is done by logrotate, which systemd runs once a day through logrotate.timer.
The /etc/logrotate.d/nginx file
/var/log/nginx/*.log {
daily
missingok
rotate 14
compress
delaycompress
notifempty
create 0640 www-data adm
sharedscripts
postrotate
if [ -f /run/nginx.pid ]; then
kill -USR1 $(cat /run/nginx.pid)
fi
endscript
}
What each line does:
daily— rotate once a day. Alternatives:weekly,size 100M(by size),maxsize 1G(on schedule, but earlier if the file grows).rotate 14— how many generations to keep. This is also where you effectively define the retention period for personal data.compress— gzip the archives; access logs typically compress about tenfold.delaycompress— compress the previous archive rather than the newest one. Mandatory together withpostrotate: time passes between renaming the file and the signal being handled, and nginx appends a few more lines to the old file in the meantime.notifempty— do not rotate empty files and create empty archives.create 0640 www-data adm— immediately create the new file with the right owner and mode. Without it the new file belongs to root with whatever umask applies.sharedscripts— runpostrotateonce for the whole file group instead of after each file. Without it, ten sites mean ten signals in a row.missingok— do not complain if a file is absent.
Why disk space is not freed without the signal
Logrotate renames access.log to access.log.1. Nothing changes for nginx: its descriptor still points at the same inode, so it keeps writing — into the archive now. When logrotate deletes the oldest generation a day later, the file disappears from the directory but the inode stays alive as long as a process holds it open. The classic symptom: df reports a 100% full disk while du on /var/log reports a hundred megabytes.
kill -USR1 tells the master process to close the current log files and open them again by path, releasing the old inode. To confirm this is what is happening:
# files that were deleted but are still held open by a process
sudo lsof +L1 | grep -i nginx
# which log files the master has open right now
sudo lsof -p $(cat /run/nginx.pid) | grep log
# reopen the logs manually
sudo kill -USR1 $(cat /run/nginx.pid)
# or through the distribution wrapper
sudo nginx -s reopen
Do not replace the signal with arestart: restarting drops active connections.USR1andnginx -s reopendo exactly what is needed without losing a single request.
Testing and debugging rotation
# dry run: show what would happen, change nothing
sudo logrotate -d /etc/logrotate.d/nginx
# forced rotation with verbose output
sudo logrotate -fv /etc/logrotate.d/nginx
# when the timer fires next
systemctl list-timers logrotate.timer
journalctl -u logrotate -n 50 --no-pager
# state: when each file was last rotated
sudo cat /var/lib/logrotate/status
Three traps that catch people most often:
- Permissions on the config itself. Logrotate ignores rule files that are world-writable or not owned by root, and says so in the
-doutput. Correct: owner root, mode 0644. - State after
-f. A forced run updates the status file, so the next scheduled rotation may be skipped. Use-dfor verification, not-f. copytruncateinstead of a signal. That mode copies the file and truncates the original without touching the descriptor. It helps when a signal is impossible, but lines are lost between the copy and the truncation, and on a large file it doubles disk usage and I/O. Nginx does not need it — the signal works.
In a container logrotate does not apply at all: logs go to stdout and must be capped by the Docker logging driver.
# /etc/docker/daemon.json — global for every container
{
"log-driver": "json-file",
"log-opts": { "max-size": "50m", "max-file": "5" }
}
Without these options a json-file log grows without limit and eventually eats the partition holding /var/lib/docker.

How long to keep logs, and the privacy problem
An IP address combined with a User-Agent, a timestamp and a list of viewed pages identifies a specific person. Under GDPR and comparable regimes that makes an access log personal data processing, with everything that follows: a lawful basis, a bounded retention period and data minimisation. Keeping raw access logs for years "just in case" is a liability, not a reserve.
A workable arrangement:
- Operational tier, 7–30 days. The full log with IPs, for incident diagnosis and attack investigation. This period is exactly what the
rotateparameter defines. - Analytical tier, longer. Aggregates without IPs: request counts per URL, status distribution, response time percentiles. No personal data left.
- Anonymise on write when the full address is not required for security purposes.
You can truncate the address inside nginx without touching the application:
# inside the http {} block: drop the last IPv4 octet and the IPv6 tail
map $remote_addr $remote_addr_anon {
~^(?<ipv4>\d+\.\d+\.\d+)\.\d+$ $ipv4.0;
~^(?<ipv6>[0-9a-fA-F]+:[0-9a-fA-F]+): $ipv6::;
default 0.0.0.0;
}
log_format anon '$remote_addr_anon - $remote_user [$time_local] '
'"$request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent" rt=$request_time';
access_log /var/log/nginx/access.log anon;
A separate and frequently forgotten problem is the query string. $request contains the full URI, which means password reset tokens, API keys and signatures passed as GET parameters end up in the log. If the application cannot be changed, log $uri instead of $request_uri — that is the path without the query string.
Check who else can see your logs. Mode 0644 on access.log, SFTP access to the directory for a contractor, log backups pushed to unencrypted object storage — each is a full-blown data leak channel that rarely makes it into the threat model.
Central log collection
Once you have more than one server, grepping each of them stops scaling. The standard setup: an agent on the host (promtail, vector, filebeat, fluent-bit) reads the file or journald, parses the JSON format and ships it to a searchable store. Three decisions to make up front:
- Format. Emitting JSON from nginx saves the agent from brittle regex parsers.
- Local copy. Keep a short local rotation even with central collection — when the network to the collector breaks, the incident logs you need are the local ones.
- Retention and access. A central store inherits every personal data requirement, only at larger volume and with more people holding access.
General principles — levels, formats, retention, stream separation — are collected in the log management guide.
How to verify
The log shows what nginx believes it returned. An external check shows what the client actually received. The gap between the two is its own class of problems: an intermediate proxy cache, a CDN, a WAF, a misdirected proxy_pass.
- Compare the status code and headers the server returns from the outside with what the access log recorded — HTTP header check. If the log says 200 and the outside world sees 301 or 403, something other than your nginx is answering.
- Find the source of the 404s in the log: a crawler locates broken internal links far faster than reverse-engineering referers — broken link checker.
- Put external monitoring in place so a 5xx spike is noticed before you open the log — uptime monitoring. The log answers "why"; monitoring answers "since when".
After any change to the format or the log paths, two commands are mandatory:
sudo nginx -t # syntax and path availability
sudo nginx -s reload # apply without dropping connections
Then make one request and confirm the line shows up where you expect it:
curl -sSI https://example.com/ -o /dev/null
sudo tail -n 1 /var/log/nginx/access.log
Frequently asked questions
Where are nginx logs on Ubuntu and Debian?
In /var/log/nginx/: access.log and error.log, alongside the rotated access.log.1, access.log.2.gz and so on. With several sites on one server each may have its own file in the same directory. The authoritative list comes from nginx -T | grep -E 'access_log|error_log'.
How do I watch nginx logs in real time?
sudo tail -f /var/log/nginx/access.log. With a filter you need --line-buffered, otherwise grep holds output in its buffer: tail -f access.log | grep --line-buffered ' 500 '. Both files at once: sudo tail -f /var/log/nginx/*.log.
Why does the log show the proxy IP instead of the visitor?
Because $remote_addr is whoever opened the TCP connection, and that is the proxy or the CDN. Configure set_real_ip_from with the actual networks of your proxies plus real_ip_header X-Forwarded-For. Until then all IP-based analytics and every IP-based ban are wrong.
The logs filled the disk — what do I do right now?
Do not just rm the file: the space will not come back while nginx holds the descriptor. The correct sequence is sudo logrotate -f /etc/logrotate.d/nginx followed by sudo kill -USR1 $(cat /run/nginx.pid). If the file is already deleted, the signal alone is enough. Check for leftovers with sudo lsof +L1.
Can I disable access_log for performance?
Almost never necessary. Start with buffer=64k flush=5s, which removes most of the disk load. Then move static assets and health checks into separate files or disable logging for those locations specifically. Disabling it entirely leaves you with no traffic data at all and makes incident analysis impossible.
How do I find a request by timestamp?
The combined timestamp format is 06/Aug/2026:14:20:07, so a plain prefix grep works: grep '06/Aug/2026:14:2' access.log gives you ten minutes. For precise ranges, awk with substr($4, 2, 17) and string comparison is easier.
Checklist
- You know the real log paths, verified through
nginx -Trather than from memory. - The format includes
$request_time,$upstream_response_time,$upstream_addrand$host. - A separate
log_formatwithescape=jsonexists for the log collector. error_logis atwarn; nodebugwas left switched on.- Behind a proxy or CDN,
set_real_ip_fromlists specific networks, never0.0.0.0/0. /etc/logrotate.d/nginxhas apostrotateblock withkill -USR1andsharedscripts.logrotate -druns clean, with no warnings about config file permissions.- Containers have
max-sizeandmax-fileset on the logging driver. - The retention period for raw logs with IPs is defined and matches the
rotatevalue. - Tokens and API keys never reach the log through the query string.
- Log directory permissions are restricted and log backups are encrypted.
- External monitoring will notice an error spike before you open the log.