logrotate defaults will quietly corrupt your PHP-FPM logs
The difference between copytruncate and create in logrotate isn't academic. Get it wrong with PHP-FPM or nginx and you'll lose log entries or rotate nothing at all.
I've debugged log rotation on probably forty servers at this point. Staging servers where logs grew to 14GB because rotation silently stopped working. Production boxes where we lost three days of PHP-FPM slow logs because the rotation happened but the process kept writing to the old file descriptor. The root cause almost every time: whoever set up the server cargo-culted a logrotate config without understanding what copytruncate and create actually do to a running process.
This one's worth understanding from first principles.
What logrotate actually does when it rotates
Logrotate has two fundamentally different strategies for handling a file that a running process has open.
The create strategy (the default) works like this:
- Rename the current log file (
access.logbecomesaccess.log.1) - Create a brand new empty file at the original path (
access.log) - Signal the process to reopen its file handles
Step 3 is everything. If the process reopens its handle, it starts writing to the new file. If it doesn't, it keeps writing to the renamed file - because on Linux, an open file descriptor follows the inode, not the filename. The file you rotated away is still receiving writes until the process closes it.
The copytruncate strategy skips the signal entirely:
- Copy the current log file to
access.log.1 - Truncate the original file to zero bytes in place
The process never gets a signal. It never reopens anything. It just keeps writing to the same file descriptor it already has, which still points to the same inode, which is now truncated. Writes continue uninterrupted.
The tradeoff is that there's a window between the copy and the truncate where new log lines land in the original file but not in the copy. On a busy server that can be thousands of lines. They're gone.
Why the defaults are wrong for PHP-FPM
Most distros ship a PHP-FPM logrotate config that looks something like this:
/var/log/php8.2-fpm.log {
rotate 12
weekly
missingok
notifempty
compress
delaycompress
postrotate
/usr/lib/php/php8.2-fpm-reopenlogs
endscript
}
That postrotate script sends USR1 to the PHP-FPM master process, which tells it to reopen log files. So on Debian/Ubuntu, the default is actually create with a proper signal - and it works. Good.
The problem is when you add a slow log or a per-pool log. Those aren't in the default config. I've seen shops add their pool log paths to a generic catch-all config like /etc/logrotate.d/php-logs that uses copytruncate because someone read a Stack Overflow answer that said "use copytruncate when you can't send a signal." Then they wonder why their slow logs have gaps.
PHP-FPM responds to USR1. Use create and send the signal. Don't use copytruncate for PHP-FPM logs if you care about completeness.
Here's what a correct config for a PHP-FPM pool slow log looks like:
/var/log/php-fpm/myapp-slow.log
/var/log/php-fpm/myapp-error.log
{
daily
rotate 30
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
if [ -f /run/php/php8.2-fpm.pid ]; then
kill -USR1 $(cat /run/php/php8.2-fpm.pid)
fi
endscript
}
The if check matters. If FPM isn't running, kill on an empty string will throw an error that logrotate logs as a rotation failure. I've seen this cause a cascade where logrotate marks the job as failed and stops rotating entirely for days.
nginx is the other landmine
nginx uses USR1 for log reopening too, and most distros ship a correct config for it in /etc/logrotate.d/nginx. But here's where I see things go sideways: custom vhost log paths.
If you define a vhost with logs going somewhere outside the default /var/log/nginx/ path - say, /srv/myapp/logs/ - that path isn't covered by the default config. So you add it. And sometimes people add it with copytruncate because they're not sure if nginx will respond to a signal.
It will. nginx absolutely responds to USR1. Use create.
/srv/myapp/logs/access.log
/srv/myapp/logs/error.log
{
daily
rotate 90
missingok
notifempty
compress
delaycompress
sharedscripts
postrotate
if [ -f /run/nginx.pid ]; then
kill -USR1 $(cat /run/nginx.pid)
fi
endscript
}
If you're running nginx inside Docker or in an environment where you don't have a PID file, that's a different conversation - and probably the one legitimate case where copytruncate makes sense for nginx. But on a standard VPS or dedicated host, you have the PID file. Use it.
The actual footgun: when copytruncate loses data
I want to be concrete about the data loss window because "there's a brief window" sounds harmless until it isn't.
On a server handling 500 requests per second, the time between cp access.log access.log.1 and truncate access.log might be 5-50 milliseconds. At 500 req/s that's 2-25 log lines gone per rotation. Not catastrophic for access logs. But if you're rotating a payment webhook log or an EHR integration audit log, missing entries is a compliance problem, not just an inconvenience.
I integrated a lab results delivery system for a biotech client a few years back. Every result delivery got logged. Their ops person had set up copytruncate on those logs because - quote - "it's safer since you don't have to restart anything." We caught it during a compliance review. The logs had gaps on every rotation day. Not big gaps, but gaps. We switched to create with a proper signal and the problem went away.
Testing that rotation actually works
Here's the thing nobody does: test it before the log fills a disk.
# Force a rotation right now, with verbose output
logrotate -vf /etc/logrotate.d/myapp
# Then check that the process reopened its handles
# For PHP-FPM, find the master PID first
PID=$(cat /run/php/php8.2-fpm.pid)
# List open file descriptors for that PID
lsof -p $PID | grep log
After a successful create-based rotation with a proper signal, the lsof output should show the process has handles on the new (empty) log files, not the rotated .1 files. If you see .1 in there, the signal didn't work.
For nginx, same thing:
lsof -p $(cat /run/nginx.pid) | grep log
Do this right after forcing rotation. If the process still has the old file open, your postrotate script is broken.
When I'd actually reach for copytruncate
There are legitimate cases:
- Processes you genuinely can't signal - legacy apps with no signal handler, third-party binaries with no docs
- Containers where the process is PID 1 and signals are awkward
- Applications that write to logs from many workers with no central process to signal
- Short-lived scripts that open and close log files on each run anyway (rotation strategy doesn't matter much here)
For any process that has a documented signal for log reopening - nginx, PHP-FPM, MySQL, PostgreSQL, Apache - use create and send the signal. That's what the signal is for.
The config mistake I see most often
A generic catch-all at the bottom of logrotate configs:
/var/log/myapp/*.log {
daily
rotate 7
copytruncate
compress
}
Someone added this to "just handle everything" and put copytruncate in because it requires no per-process knowledge. Then PHP-FPM logs got added to that directory. Then nginx logs. Now everything rotates with potential data loss and nobody notices until they need the logs for something that matters.
Give each process its own logrotate config in /etc/logrotate.d/. A file per service. It takes twenty minutes and it means you can reason about what each process needs.
When I'd skip logrotate entirely
If I'm running a newer stack with systemd, I'll often use journald for PHP-FPM and nginx and skip file-based log rotation entirely. journald handles rotation natively, size limits are built in, and journalctl gives me structured filtering without grep archaeology. But a lot of my clients are on older setups or have existing log pipelines pointed at files, so logrotate stays in the picture for most of what I manage.
For hosted clients where I'm doing the ops work, I now have a standard checklist: after any new service install, verify the logrotate config, force a rotation, and confirm with lsof that the process reopened. Fifteen minutes up front saves a disk-full incident at 2am.
The defaults aren't malicious, they're just generic. Distro maintainers can't know your log paths or your process tree. That's your job - and it's worth doing it once, correctly.
Need help shipping something like this? Get in touch.