How to activate Nginx logging on a Linux PHP Azure App Service

Azure App Service PHP for Linux uses Nginx as its web-server. The standard way to get HTTP access logs is to activate Diagnostics Settings export. The logs are collected by Azure in front of Nginx. They contain many useful information but there are cases when native Nginx logs can bring details not available on the Diagnostics Settings logs. This article will show how to enable and operate native Nginx access logging: persist logs to an Azure File Share, keep per-instance buffered JSONL files, and rotate them safely on a schedule, so scale-out works without corrupted files. The same pattern applies to other stacks (ASP.NET, Node.js, etc.) whenever Nginx is used as the web server or reverse proxy.

What is covered:

  • setting up a storage outside the App Service
  • creating a custom startup script for setting up Nginx
  • configuring Nginx to write log files
  • rotating the log files regularly



Final result on the storage account: Nginx log files stored on Azure File Share, one for each instance, rotated every hour.



Activate Nginx logging

The default PHP 8.4 configuration of Azure App Service for Linux uses Nginx. Unfortunately, the native Nginx http logging is explicitly disabled in the main configuration file /etc/nginx/nginx.conf by the command access_log off;. To re-activate it, we need to switch the access_log directive to on and reload Nginx configuration with service nginx reload command.

Because the default configuration does not set-up the logging, we need a custom configuration file in /etc/nginx/sites-enabled/. Every files in this folder are automatically loaded by Nginx at startup, due to the include /etc/nginx/sites-enabled/*; instruction in the /etc/nginx/nginx.conf.

Log storage

In general nginx log files could be stored on:

  • local storage, in a subfolder outside /home. It is not a good idea. The local storage is local to the single instance and it is not persistent and will be lost at every reboot.
  • shared storage, in a subfolder under /home. The storage is persisted across reboots but the space is limited and depend on the chosen App Service plan.
  • external storage, using a mounted Azure File Share. This is the best option, because the storage is persistent, shared across all instances in case of scale-out and can accommodate many and large log files.

To store the log files to an external storage, we need to create a dedicated Azure File Share, mount it on every instances and configure Ngnix accordingly. But this is not enough. There are two issues: the log file will grow indefinitely and, in case of scale-out every instance will write to the same log file, creating a corrupted file. Two solutions:

  1. instruct Nginx to create a new log file every day (or hour, or minute, etc), using variables in the log file name. E.g. access_log /home/nginxlogs/http_${hostname}_${log_year}${log_month}${log_day}.json logjson At first glance it seems working fine but using variables in the log file name automatically disables the internal log file buffering of Nginx, resulting in a continuous writing: an access to the file system on every http request! The storage account will be stressed with high impact on costs. So, not a good solution.
  2. always write to a log file using a static name, leveraging Nginx buffering, and use a custom script to regularly move the current log file and create a new one. See details below for the script approach.

In this post we will use the external storage option, mapping an Azure File Share to /applogs, with some subfolders: http, status and temp:

  1. Create an Azure Storage Account or use an existing one, in the same region of the App Service to avoid bandwidth usage costs.
  2. Create a dedicated File Share, e.g. applogs
  3. Mount the File Share in the App Service configuration section, e.g. /applogs


Scripts and configuration files

We need two scripts and two configuration files:

  • setup.sh, executed on every instance startup, to:
    • enable nginx logging.
    • copy the custom logging configuration file.
    • run logrotate script the first time, at startup.
    • install, configure and start cron for the following runs of the logrotate script.
  • nginxlogrotate.sh, executes every hour, triggered by cron, to rotate the log files:
    • builds a new nginx log file name
    • updates the nginx configuration file using sed command
    • reloads nginx configuration
  • cron.conf, the cron configuration file.
  • nginxlog.conf, the Nginx custom configuration file for setting the logging.



For log rotation, we cannot use standard tools like logrotate because it does not fit well with app-service when scaled-out. Every instance of nginx inside app-service must write its own log files, with unique name, and manage its own log rotation with a custom script, scheduled every hour using cron.

The setup.sh script must be set as startup script in the App Service configuration:


Scale out warnings !

  • When we ask for a scaling out, the App Service will not scale out immediately. Normally it requires some minutes. So be patient.

  • When we do SSH into the App Service through Azure Portal, we connect to first instance. Editing files under /home will be visible to all instances because it is shared, but running scripts and commands will have effect only on the first instance. So be careful when running setup.sh, nginxlogrotate.sh, etc. from shell. At the end of modifications and tests, it is better to restart the whole App Service from Azure Portal. This way, every instance will restart, re-executing the startup script.


Files

/home/mysetup/setup.sh

WRITELOG() {
  logdt=$(date '+%Y%m%d-%H%M%S')
  logid=$(date '+%Y%m%d')
  logmsg=$logdt' : '$1
  echo "$logmsg" >>"/applogs/status/startup_${logid}.log"
}

mkdir -p /applogs/http
mkdir -p /applogs/status
mkdir -p /applogs/temp

WRITELOG '---------------------------------------------------------'
WRITELOG 'starting instance'
WRITELOG 'hostname: '$(hostname)

# Setup nginx: activate logging, copy log conf file
WRITELOG 'nginx setup config files'
sed -i 's/access_log off;/access_log on;/g' /etc/nginx/nginx.conf
cp /home/mysetup/nginxlog.conf /etc/nginx/sites-enabled/
/home/mysetup/nginxlogrotate.sh

# Install and configure cron
WRITELOG 'apt: update'
apt-get update -qq
WRITELOG 'apt: install cron'
apt-get install cron -qq
WRITELOG 'cron start'
service cron start
crontab /home/mysetup/cron.conf

WRITELOG 'THE END' 

/home/mysetup/nginxlogrotate.sh

#!/bin/sh
echo '--- NGINX LOG ROTATE ---'
LOGFOLDER="/applogs/http/"
NGINX_LOG_CONFIG_FILE="/etc/nginx/sites-enabled/nginxlog.conf"
NGINX_LOG_CONFIG_PARAMS="buffer=32k flush=1m"
dtnow=$(date '+%Y%m%d_%H%M%S')
instanceid=${HOSTNAME}
logfilenameFP=${LOGFOLDER}${dtnow}_${instanceid}.jsonl
new_accesslog_line="access_log ${logfilenameFP} logjson ${NGINX_LOG_CONFIG_PARAMS};"
echo $new_accesslog_line
sed --in-place --regexp-extended "s|^[ \t]*access_log .*|${new_accesslog_line}|" "$NGINX_LOG_CONFIG_FILE"
service nginx reload

/home/mysetup/cron.conf

0 * * * * /bin/sh -l /home/mysetup/nginxlogrotate.sh >> /var/log/nginxlogrotate.log 2>&1

/home/mysetup/nginxlog.conf

The nginx logging configuration, setup an json-lines output format, with some custom fields. Obviously we can also use standard text format, with ot without custom fields.

  # Notes:
  #  - $log_uristem : $request_uri contains uri with parameters. The regular expression skips the parameters.
  #                   $uri does not work as exptected. It always add index.html or index.php when there is no explicit page.
  
  map $time_iso8601 $log_year   { default '0000'; "~^(\d{4})-(\d{2})-(\d{2})" $1; }
  map $time_iso8601 $log_month  { default '00';   "~^(\d{4})-(\d{2})-(\d{2})" $2; }
  map $time_iso8601 $log_day    { default '00';   "~^(\d{4})-(\d{2})-(\d{2})" $3; }
  map $time_iso8601 $log_hour   { default '00';   "~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})" $4; }
  map $time_iso8601 $log_minute { default '00';   "~^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})" $5; }
  
  map $time_iso8601         $log_dt         { "~^(\d{4}-\d{2}-\d{2})" $1; } 
  map $time_iso8601         $log_tm         { "~^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})" $2; }
  map $http_x_forwarded_for $log_c_ip_first { "~^\[?(\d+\.\d+\.\d+\.\d+|[a-fA-F0-9:]+)\]?" $1; default $remote_addr; }
  map $sent_http_X_WP_Cache $log_cached     { default $sent_http_X_WP_Cache; '' 0; } 
  map $request_uri          $log_uristem    { "~^(?<path>[^?]*)" $path; default ""; }
  
  log_format logjson escape=json
  '{ '
    '"DT": "$log_dt", '
    '"TM": "$log_tm", '
    '"Method": "$request_method", '
    '"Status": "$status", '
    '"CsHost": "$host", '
    '"CsUriStem": "$log_uristem", '
    '"CsUriQuery": "$query_string", '
    '"SPort": "$server_port", '
    '"CIp": "$log_c_ip_first", '
    '"CIPLast": "$remote_addr", '
    '"CPortLast": "$remote_port", '
    '"SHostname": "$hostname", '
    '"CsBytes": "$request_length", '
    '"ScBytes": "$bytes_sent", '
    '"TimeTakenS": "$request_time", '
    '"Cached": "$log_cached", '
    '"UserAgent": "$http_user_agent", '
    '"HttpReferer": "$http_referer", '
    '"SetCookies": "$sent_http_set_cookie", '
    '"XForwardedFor": "$http_x_forwarded_for" '
  '}';
  
  access_log /applogs/temp/temp.jsonl logjson; 
  
  # THIS or similar ones, do not work as expected :( 
  # access_log /home/nginxlogs/${log_year}/${log_month}/${log_day}/000000-${log_year}${log_month}${log_day}_${log_hour}00.json logjson;  



Output examples

startup.log

Notice the long time required for the apt installation of cron.

20251004-121325 : starting instance
20251004-121325 : hostname: 71d9f7341a1f
20251004-121325 : nginx setup config files
20251004-121325 : apt: update
20251004-121342 : apt: install cron
20251004-121421 : cron start
20251004-121421 : THE END

Internal docker log file

This an extract of the internal docker log file, available under /home/LogFiles, showing up the startup sequence of the App Service instance.

2025-10-04T12:12:55.5982095Z Container start method called.
2025-10-04T12:12:55.5990245Z Establishing network.
2025-10-04T12:12:55.5992999Z Pulling image: appsvc/php:8.4-fpm_20250722.4.tuxprod.
2025-10-04T12:12:57.4196987Z Container is starting.
...
2025-10-04T12:13:03.3589112Z Starting container: cb8e2e9xxxxxxxxxxxxxxxxwetd7hd.
2025-10-04T12:13:03.6107379Z Container is running.
2025-10-04T12:13:04.2135016Z Container start method finished after 8596 ms.
2025-10-04T12:13:26.0488990Z Site startup probe succeeded after 21.8131764 seconds.
2025-10-04T12:13:29.1315408Z Site started.
2025-10-04T12:13:29.2322461Z Site is running with patch version: 8.4.10

Http log file (json-lines)

{ "DT": "2025-10-11", "TM": "14:04:09", "Method": "GET", "Status": "200", "CsHost": "xxxxxxxxxxxxxxxxxx.azurewebsites.net", "CsUriStem": "/page2.php", "CsUriQuery": "", "SPort": "8080", "CIp": "79.44.xxx.xxx", "CIPLast": "169.254.130.1", "CPortLast": "42459", "SHostname": "360abbc3d45a", "CsBytes": "1518", "ScBytes": "508", "TimeTakenS": "0.824", "Cached": "0", "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) .....", "HttpReferer": "https://xxxxxxxxxxxxxxx.azurewebsites.net/page1.php", "SetCookies": "userid=123456; expires=Thu, 01 Jan 2026 01:01:01 GMT; Max-Age=7037812; path=/", "XForwardedFor": "79.44.xxx.xxx:64403" }
{ "DT": "2025-10-11", "TM": "14:04:10", "Method": "GET", "Status": "200", "CsHost": "xxxxxxxxxxxxxxxxxx.azurewebsites.net", "CsUriStem": "/page1.php", "CsUriQuery": "", "SPort": "8080", "CIp": "79.44.xxx.xxx", "CIPLast": "169.254.130.1", "CPortLast": "42459", "SHostname": "360abbc3d45a", "CsBytes": "1518", "ScBytes": "432", "TimeTakenS": "0.008", "Cached": "1", "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) .....", "HttpReferer": "https://xxxxxxxxxxxxxxx.azurewebsites.net/page2.php", "SetCookies": "", "XForwardedFor": "79.44.xxx.xxx:64403" }
{ "DT": "2025-10-11", "TM": "14:04:13", "Method": "GET", "Status": "200", "CsHost": "xxxxxxxxxxxxxxxxxx.azurewebsites.net", "CsUriStem": "/page2.php", "CsUriQuery": "", "SPort": "8080", "CIp": "79.44.xxx.xxx", "CIPLast": "169.254.130.1", "CPortLast": "42451", "SHostname": "360abbc3d45a", "CsBytes": "1518", "ScBytes": "508", "TimeTakenS": "1.190", "Cached": "0", "UserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) .....", "HttpReferer": "https://xxxxxxxxxxxxxxx.azurewebsites.net/page1.php", "SetCookies": "userid=123456; expires=Thu, 01 Jan 2026 01:01:01 GMT; Max-Age=7037809; path=/", "XForwardedFor": "79.44.xxx.xxx:56194" }



Misc

A couple of php pages to test the logging solution.

page1.php

<?php
session_start();
header('X-WP-Cache: 1');
?>
<html><body>Page1<br/><a href="page2.php">Page2</a></body></html>

page2.php

<?php
session_start();
//header('X-Fabrizio: Hello');
$cookie_name = "userid";
$cookie_value = "123456";
setcookie($cookie_name, $cookie_value,  1767229261 , "/");
usleep(800000);
?>
<html><body>Page2<br/><a href="page1.php">Page1</a></body></html>