2026年11月13日

2026年11月13日

Customize the Nginx Log Format: log_format, JSON and Conditional Logs

This article is a localized edition of the Japanese original. Read the Japanese original

The format of Nginx access logs is defined with log_format under a name of your choice and then used by naming it in access_log. The default format (combined) does not include how long a response took or anything about the backend, so for tracking down slow requests or feeding a monitoring system you create a format with extra fields.

The configuration in this article was checked with nginx -t and real log output on Nginx 1.30.6 (the official nginx.org Docker image) as of November 2026. The directive behavior follows the official documentation (ngx_http_log_module).

log_format and access_log basics

Directive Where it can go Role
log_format name [escape=default/json/none] string ...; http block only Defines a log format
access_log path [format] [buffer=] [flush=] [if=]; http, server, location and others Sets which file to write and in which format

If you put log_format inside server or location, nginx -t reports this error:

nginx: [emerg] "log_format" directive is not allowed here in /etc/nginx/conf.d/bad.conf:1

If access_log names no format, the predefined combined format is used. Here is what combined contains:

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

The name combined is built in, so you cannot redefine it. Give your own format a different name.

Where to put the configuration file

log_format has to end up inside the http block. With the Debian/Ubuntu packages and the official nginx.org packages, the http block of /etc/nginx/nginx.conf contains include /etc/nginx/conf.d/*.conf;, so a file placed in conf.d behaves as if it were written in the http block. Check it to be sure:

grep -n "include" /etc/nginx/nginx.conf

Here the formats are defined in /etc/nginx/conf.d/log_format.conf, and each site's server block sets access_log. Edit configuration files with an editor. If you write them with shell double quotes, as in echo "... $remote_addr ..." > file, the shell expands $remote_addr and the like as shell variables and they disappear.

A format with response times and backend details

This example appends the host name, processing time and backend details (the target of proxy_pass or fastcgi_pass) to combined.

# /etc/nginx/conf.d/log_format.conf
log_format main_ext '$remote_addr - $remote_user [$time_local] "$request" '
                    '$status $body_bytes_sent "$http_referer" "$http_user_agent" '
                    'host=$host rt=$request_time '
                    'uct=$upstream_connect_time urt=$upstream_response_time '
                    'ua=$upstream_addr us=$upstream_status';
# server block of each site
server {
    server_name example.com;
    access_log /var/log/nginx/example.access.log main_ext;
    # ...
}

In the test environment the output looked like this. For requests that do not go to a backend, the $upstream_* fields are -.

127.0.0.1 - - [10/Nov/2026:09:32:37 +0000] "GET /app/x HTTP/1.1" 200 8 "http://ref/?a=b" "Mozilla \x22quote\x22 test" host=127.0.0.1 rt=0.000 uct=0.000 urt=0.000 ua=127.0.0.1:8080 us=200
127.0.0.1 - - [10/Nov/2026:09:32:37 +0000] "GET / HTTP/1.1" 200 3 "-" "curl/8.14.1" host=127.0.0.1 rt=0.000 uct=- urt=- ua=- us=-

With the default escaping (escape=default), characters such as ", \ and control characters are replaced with sequences like \x22.

Commonly used variables

Variable Contents
$remote_addr Client IP address (behind a load balancer or similar, this is that device's address)
$http_x_forwarded_for The request's X-Forwarded-For header
$time_local / $time_iso8601 Time (Common Log Format / ISO 8601)
$msec Time the log entry was written (UNIX time with milliseconds)
$request The full request line (for example GET /path HTTP/1.1)
$request_method / $request_uri Method / URI including arguments
$host Host name of the request
$status Response status code
$body_bytes_sent / $bytes_sent Bytes of body sent / total bytes including headers
$request_length Length of the received request (headers and body)
$request_time Seconds from reading the first byte to sending the last byte (millisecond resolution)
$upstream_connect_time Seconds spent connecting to the backend
$upstream_header_time Seconds until the response headers arrived from the backend
$upstream_response_time Seconds until the full response arrived from the backend
$upstream_addr / $upstream_status Address of the backend actually used / status of its response
$request_id A 16-byte ID generated per request (32 hex characters)
$ssl_protocol / $ssl_cipher TLS version / cipher suite (for HTTPS)

When several backends were tried, the $upstream_* variables hold multiple comma-separated values such as 0.001, 0.002.

If $request_time is large while $upstream_response_time is small, the time went into sending to the client (a slow connection, a large file and so on) rather than into the backend. If both are large, look at the backend's processing.

Logging as JSON (escape=json)

When you ship logs to a collector (Elasticsearch, Loki, a cloud logging service and so on), JSON saves you from parsing fields out. With escape=json, " and \ inside values are escaped as valid JSON, and variables without a value become empty strings.

# /etc/nginx/conf.d/log_format.conf
log_format json_combined escape=json
    '{'
        '"time":"$time_iso8601",'
        '"remote_addr":"$remote_addr",'
        '"host":"$host",'
        '"method":"$request_method",'
        '"uri":"$request_uri",'
        '"protocol":"$server_protocol",'
        '"status":$status,'
        '"bytes_sent":$body_bytes_sent,'
        '"referer":"$http_referer",'
        '"user_agent":"$http_user_agent",'
        '"request_time":$request_time,'
        '"upstream_response_time":"$upstream_response_time",'
        '"request_id":"$request_id"'
    '}';

Output from the test environment (one line per request):

{"time":"2026-11-10T09:32:37+00:00","remote_addr":"127.0.0.1","host":"127.0.0.1","method":"GET","uri":"/app/x","protocol":"HTTP/1.1","status":200,"bytes_sent":8,"referer":"http://ref/?a=b","user_agent":"Mozilla \"quote\" test","request_time":0.000,"upstream_response_time":"0.000","request_id":"5c4f1ceb37b553029aa364001c192a6c"}

$status, $body_bytes_sent and $request_time are always numbers, so they are written unquoted as numbers. $upstream_response_time is empty when there is no value and can hold several comma-separated values, so it is treated as a string.

Logging, or not logging, based on a condition

Write only error responses to a separate file

If you give access_log an if= variable, the entry is written only when that value is neither empty nor 0. The map goes in the http block.

# /etc/nginx/conf.d/log_format.conf
map $status $loggable {
    ~^[23]  0;
    default 1;
}
# server block
access_log /var/log/nginx/example.access.log main_ext;
access_log /var/log/nginx/example.error-status.log main_ext if=$loggable;

When a block has several access_log lines, every file is written. In the test environment, 2xx and 3xx responses were recorded only in the first file, while a 404 response was recorded in both.

Skip health checks and similar requests

location = /healthz {
    access_log off;
    return 200 "ok\n";
}

If a lower-level block (such as location) has its own access_log, the access_log of the enclosing block is not inherited; only that location's setting applies.

Reducing writes with a buffer

On busy servers, adding buffer= makes Nginx write in batches instead of hitting the disk for every request.

access_log /var/log/nginx/example.json.log json_combined buffer=32k flush=5s;
  • buffer=32k: write once 32 KB has accumulated
  • flush=5s: write after 5 seconds even if the buffer is not full

Writes are delayed, so entries do not appear right away in tail -f. Each worker process has its own buffer, and in the test environment lines sometimes appeared out of chronological order.

Applying and checking

sudo nginx -t
sudo systemctl reload nginx
curl -s -o /dev/null http://example.com/
sudo tail -n 3 /var/log/nginx/example.access.log

A reload is enough; no restart is needed. Nginx creates new log files on its own.

You can count entries per status code like this (in combined and main_ext, the ninth field is the status code):

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

Log rotation

The Debian/Ubuntu packages ship /etc/logrotate.d/nginx, which rotates /var/log/nginx/*.log daily and keeps 14 generations (checked with the Debian 13 package). New log files are covered too as long as they live in /var/log/nginx/ with a .log name. If you put them elsewhere, add them to the logrotate configuration. To make Nginx open the new files after rotation, use nginx -s reopen.

Things to watch

  • Logs keep IP addresses and URL arguments (search terms, tokens and so on). Limit who can read them and decide how long to keep them.
  • Changing the format can make tools that read the logs (GoAccess, awk scripts, monitoring settings and so on) pick the wrong field positions. Writing to a new file name is safer than changing the format of an existing file.
  • The format of the error log (error_log) cannot be changed. Only the access log format is customizable.

To aggregate and browse logs, see How to analyze Nginx access logs with GoAccess, and for writing conditions, see How to use the Nginx map module.

Questions about WordPress? The publisher of this site is based in Japan and replies in Japanese. Contact (Japanese)

お気軽にご相談ください

お見積りへ お問い合わせへ
▲