How to Log Nginx Access and Error Logs

How to Log Nginx Access and Error Logs

Logs are the first place I look whenever something goes wrong on a server — a spike in 500 errors, a suspicious traffic pattern, a client complaining a feature “just doesn’t work.” Nginx’s logging system is powerful but often left at its defaults, which means a lot of useful diagnostic information never actually gets captured. In this guide, I’m going to go deep into how Nginx access and error logging actually works, how to customize log formats to capture what you actually need, and how to use these logs effectively for debugging and monitoring.

Understanding Nginx’s Two Log Types

Nginx produces two distinct types of logs:

  • Access logs — record every request that hits the server: client IP, request method, URL, status code, response size, user agent, referrer, and more (depending on the format you define).
  • Error logs — record internal Nginx issues: configuration problems, upstream connection failures, permission errors, worker process crashes, and (depending on log level) even detailed debug information.

They serve very different purposes. Access logs tell you what happened from a traffic perspective; error logs tell you what went wrong from Nginx’s own operational perspective.

Requirements

  • Nginx installed and running
  • Root or sudo access to edit configuration files
  • Basic familiarity with the Nginx config structure (http, server, location blocks)

Step 1: Locate the Default Log Files

On most distributions, default log paths are:

/var/log/nginx/access.log
/var/log/nginx/error.log

You can confirm the paths your installation actually uses:

sudo nginx -T | grep -E "access_log|error_log"

-T dumps the full effective configuration (including included files), which is the most reliable way to see exactly what’s active, rather than guessing from individual config files.

Step 2: The Default Access Log Format

By default, Nginx uses the combined log format, defined in nginx.conf:

log_format combined '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status $body_bytes_sent '
                     '"$http_referer" "$http_user_agent"';

A typical line looks like this:

203.0.113.45 - - [15/Aug/2026:10:22:31 +0000] "GE T /products?id=42 HTTP/1.1" 200 5423 "https://example.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"

Breaking this down:

  • $remote_addr — client IP address
  • $remote_user — authenticated user (if HTTP auth is used, otherwise -)
  • $time_local — timestamp
  • $request — the full request line (method, path, protocol)
  • $status — HTTP response status code
  • $body_bytes_sent — size of the response body
  • $http_referer — the referring URL
  • $http_user_agent — client’s browser/user agent string

This is enabled per server block with:

access_log /var/log/nginx/access.log combined;

If you don’t specify a format name, Nginx uses combined by default anyway, so this is often omitted.

Step 3: Creating a Custom Log Format

The default format is fine for basic use, but I almost always extend it. Here’s a format I use frequently, which adds request timing, upstream response time, and forwarded IP (critical when running behind a load balancer or CDN):

log_format detailed '$remote_addr - $remote_user [$time_local] '
                     '"$request" $status $body_bytes_sent '
                     '"$http_referer" "$http_user_agent" '
                     'rt=$request_time uct="$upstream_connect_time" '
                     'uht="$upstream_header_time" urt="$upstream_response_time" '
                     'xff="$http_x_forwarded_for"';

Key additions and why I include them:

  • $request_time — total time Nginx spent processing the request, from first byte received to last byte sent. Invaluable for spotting slow endpoints.
  • $upstream_response_time — time the upstream (your app server) took to respond. Comparing this to $request_time tells you whether slowness is in your app or in Nginx/network overhead.
  • $upstream_connect_time and $upstream_header_time — more granular breakdowns of where time is spent talking to the upstream.
  • $http_x_forwarded_for — useful when Nginx itself sits behind another proxy or load balancer, so you can see the original chain of forwarded IPs.

Apply it:

access_log /var/log/nginx/access.log detailed;

For JSON-structured logs (which I use whenever logs are being shipped to something like Elasticsearch, Loki, or a SIEM), I define:

log_format json_combined escape=json '{'
    '"time_local":"$time_local",'
    '"remote_addr":"$remote_addr",'
    '"remote_user":"$remote_user",'
    '"request":"$request",'
    '"status": "$status",'
    '"body_bytes_sent":"$body_bytes_sent",'
    '"request_time":"$request_time",'
    '"http_referer":"$http_referer",'
    '"http_user_agent":"$http_user_agent",'
    '"upstream_response_time":"$upstream_response_time"'
'}';

access_log /var/log/nginx/access.log.json json_combined;

JSON logs are dramatically easier to parse programmatically and integrate cleanly with modern log aggregation pipelines, at the cost of being slightly less human-readable when tailing manually.

Step 4: Configuring the Error Log and Log Levels

The error log directive controls both the file path and the severity level of what gets logged:

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

Available levels, from least to most verbose:

  • emerg — system is unusable
  • alert — action must be taken immediately
  • crit — critical conditions
  • error — error conditions
  • warn — warning conditions
  • notice — normal but significant events
  • info — informational messages
  • debug — detailed debug information (requires Nginx compiled with --with-debug)

I use warn in production as a good balance — it catches real problems without flooding the log with noise. During active debugging of a tricky issue, I’ll temporarily bump this to info or debug.

A typical error log entry looks like:

2026/08/15 10:24:02 [error] 1523#1523: *892 connect() failed (111: Connection refused) while connecting to upstream, client: 203.0.113.45, server: example.com, request: "GE T /api/data HTTP/1.1", upstream: "http://127.0.0.1:3000/api/data", host: "example.com"

This tells you exactly what failed (connect() failed), why (Connection refused — your upstream app probably isn’t running), which client triggered it, and the exact request and upstream URL involved. This level of detail is why I never turn error logging off, even on quiet, low-priority sites.

Step 5: Conditional and Selective Logging

Logging every single request isn’t always useful — health checks and static asset requests can flood your logs with noise. I use conditional logging to filter these out:

map $request_uri $loggable {
    ~^/health    0;
    ~^/favicon.ico    0;
    default    1;
}

server {
    access_log /var/log/nginx/access.log combined if=$loggable;
}

This skips logging for any request matching /health or /favicon.ico, while logging everything else normally. I find this particularly useful on servers with frequent uptime monitoring pings, which would otherwise dominate the log volume.

Step 6: Disabling Logs for Static Assets (Selectively)

For high-traffic static file locations where you don’t need per-request logging (e.g., serving thousands of image requests per minute), you can disable access logging entirely for that location while keeping error logging active:

location /images/ {
    access_log off;
    root /var/www/example.com;
}

I use this sparingly — usually only on extremely high-volume static asset paths where the log volume genuinely creates operational overhead and I have alternate visibility (e.g., a CDN’s own analytics).

Step 7: Per-Virtual-Host Logging

For servers hosting multiple sites, always separate logs per site rather than one combined file — it makes debugging dramatically easier:

server {
    server_name blog.example.com;
    access_log /var/log/nginx/blog.access.log combined;
    error_log /var/log/nginx/blog.error.log warn;
}

server {
    server_name shop.example.com;
    access_log /var/log/nginx/shop.access.log combined;
    error_log /var/log/nginx/shop.error.log warn;
}

Testing Your Logging Configuration

After any log configuration change:

sudo nginx -t
sudo systemctl reload nginx

Generate a test request and confirm it’s captured:

curl http://example.com/
tail -n 5 /var/log/nginx/access.log

Trigger an intentional error (request a nonexistent upstream route, or stop your backend temporarily) and confirm the error log captures it:

tail -n 5 /var/log/nginx/error.log

For custom log formats, I always verify the actual output matches what I expect before deploying broadly — it’s easy to make a typo in a log_format string that produces malformed or misaligned log lines.

Analyzing Logs Effectively

A few commands I use constantly for quick log analysis:

Top requested URLs:

awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

Top client IPs:

awk '{print $1}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -20

Count of each HTTP status code:

awk '{print $9}' /var/log/nginx/access.log | sort | uniq -c | sort -rn

Requests slower than 1 second (using the detailed format with $request_time):

awk '{ split($0,a,"rt="); split(a[2],b," "); if (b[1]+0 > 1) print }' /var/log/nginx/access.log

Live tail filtered to errors only:

tail -f /var/log/nginx/error.log | grep -i error

For anything beyond ad-hoc analysis, I strongly recommend a proper log analysis tool — GoAccess is my go-to for a fast, real-time terminal (or HTML) dashboard directly from Nginx logs:

sudo apt install goaccess -y
goaccess /var/log/nginx/access.log --log-format=COMBINED

Troubleshooting Common Issues

Logs not appearing at all — Check the access_log and error_log directive isn’t accidentally set to off in a parent context, and confirm the Nginx worker process has write permission to the log directory.

Duplicate log entries — Usually caused by access_log being declared multiple times across nested contexts (e.g., both in http and server blocks) without intending to.

Custom log format shows blank/dash fields — This usually means the variable name is misspelled, or the field genuinely has no value for that request type (e.g., $upstream_response_time will be - for requests that never reach an upstream, like static file serving).

Log file grows without limit — This is a log rotation problem, not a logging configuration problem — see the companion guide on rotating Nginx log files.

Security Considerations

  • Avoid logging sensitive data. If URLs contain tokens or session identifiers as query parameters, they’ll end up in your access logs in plaintext. Where possible, fix the application to avoid putting secrets in URLs; where not possible, consider a custom log format that masks specific query parameters.
  • Restrict log file access. Logs often reveal user behavior patterns, IP addresses, and sometimes account identifiers — treat them as sensitive data with restricted file permissions (0640 and appropriate group ownership).
  • Be mindful of log shipping destinations. If forwarding logs to a third-party service, ensure the transport is encrypted (TLS) and access to the destination is properly authenticated.
  • Log $http_x_forwarded_for carefully. This header is client-controlled and can be spoofed — don’t treat it as a trusted source of truth for IP-based security decisions unless you’re also validating it against a trusted proxy chain (set_real_ip_from).

Performance Tips

  • Buffering writes reduces disk I/O overhead on high-traffic servers:
access_log /var/log/nginx/access.log combined buffer=32k flush=5s;

This buffers log writes in 32KB chunks and flushes at least every 5 seconds, rather than writing to disk on every single request.

  • Disable access logging for extremely high-volume, low-value paths (health checks, static assets already tracked elsewhere).
  • Use conditional logging (if=$loggable) rather than disabling logging entirely, so you retain visibility into genuine errors even on filtered paths.

Real-World Use Cases

  • Debugging intermittent 502/504 errors: The detailed format with upstream timing fields has repeatedly let me pinpoint whether slowness originates in the app or the network layer.
  • Security incident investigation: Access logs with accurate $http_x_forwarded_for and $remote_addr fields were essential in tracing the origin of a credential-stuffing attack I dealt with on a client’s login endpoint.
  • Capacity planning: Aggregating $request_time and traffic volume over weeks helps justify scaling decisions with actual data rather than guesswork.

Best Practices I Follow

  1. Always customize the log format to include $request_time and $upstream_response_time — the defaults leave out the most useful debugging fields.
  2. Use JSON-formatted logs whenever shipping to a centralized log system.
  3. Set error_log to warn in production, escalating temporarily to debug only while actively troubleshooting.
  4. Separate access and error logs per virtual host on multi-site servers.
  5. Use conditional logging to filter out health checks and monitoring noise without losing real error visibility.
  6. Buffer log writes on high-traffic servers to reduce disk I/O.
  7. Never disable error logging, even on quiet sites — it’s your first signal when something breaks.
  8. Regularly audit logs for sensitive data leakage via query strings or headers.

Wrapping Up

Nginx’s logging system is far more flexible than the default combined format suggests. Once you start customizing log formats to capture timing data, structuring logs as JSON for easier analysis, and filtering out noise while keeping real signal, your logs go from “records that exist” to a genuinely useful diagnostic and analytics tool. I’d recommend picking one production server right now, reviewing its current log format, and adding at least $request_time and $upstream_response_time if they’re missing — it’s a small change that pays off the very next time something goes slow.

Total
1
Shares

Leave a Reply

Previous Post
How to Set Up Nginx with WebSockets

How to Set Up Nginx with WebSockets

Next Post
How to Rotate Nginx Log Files

How to Rotate Nginx Log Files

Related Posts