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 accumulatedflush=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)