Laravel Log Rotation on a Single VM: The Octane File-Handle Trap
Running Octane and logrotate on the same VM looks fine until your logs silently vanish. Here's what's actually happening and how to fix it.
Logrotate runs at 2 AM, rotates laravel.log to laravel.log.1, creates a fresh laravel.log, and your monitoring says everything is fine. But Octane is still writing to the old inode — the one that's now laravel.log.1 — and the new file stays empty until you restart the worker. This is a silent failure. No exceptions, no alerts, just a log file that stops growing while your application keeps running.
I ran into this with a healthcare client's scheduling app running on a single DigitalOcean droplet. We'd switched to Octane for a performance lift on their booking API, and a few weeks later their on-call person noticed that log-based alerting had gone quiet overnight. Not because nothing was happening — because logs were piling up in a rotated file nobody was watching.
Why This Happens
On a traditional PHP-FPM setup, each request spawns a short-lived process. That process opens the log file, writes, and closes it. When logrotate rotates the file, the next request opens the new laravel.log fresh. No stale handles.
Octane is different. It boots a long-running worker — Swoole or RoadRunner — that stays resident between requests. The file handle your application opened on boot stays open. On Linux, when logrotate renames laravel.log to laravel.log.1, that rename doesn't close any file descriptors. The worker still holds an open handle to the old inode. Writes keep going to laravel.log.1. The new laravel.log gets nothing.
This is actually how POSIX file systems are supposed to work. It's not a bug in Octane or logrotate. It's two tools operating correctly at different layers, in ways that don't compose well without configuration.
The logrotate Side
Here's a typical logrotate config for a Laravel app, probably sitting at /etc/logrotate.d/laravel:
/var/www/myapp/storage/logs/laravel.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0664 www-data www-data
}
This is fine for FPM. For Octane, it's not enough. You need a postrotate script that signals the worker to reopen its log file.
The fix looks like this:
/var/www/myapp/storage/logs/laravel.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0664 www-data www-data
postrotate
php /var/www/myapp/artisan octane:reload --no-ansi > /dev/null 2>&1 || true
endscript
}
octane:reload sends a graceful reload signal to the running workers. They finish any in-flight requests, then re-initialize — which means reopening the log file handle against the new laravel.log.
The || true keeps logrotate from treating a failed reload as a logrotate failure. That matters if Octane isn't running (scheduled maintenance, deploys) — you don't want cron noise.
The Octane Side: Are You Even Logging Where You Think?
Before you touch logrotate, confirm how Octane is logging. Check your config/logging.php:
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'], // <-- are you on 'single' or 'daily'?
'ignore_exceptions' => false,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 14,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
],
],
If you're on the daily driver, Laravel itself rotates the log by date — it creates laravel-2025-01-15.log, etc. In that case, logrotate touching laravel.log is a non-issue because that file barely exists. But you might still have the stale-handle problem on the date-stamped file if the worker rolls over at midnight.
For Octane on a single VM, I'd actually recommend switching to single driver and letting logrotate own rotation entirely. One tool doing one job.
Verify the Inode Yourself
Before and after a rotation, you can confirm what's happening:
# Find the Octane worker PID
pgrep -a php
# Check which files it has open
lsof -p <pid> | grep laravel
Before rotation, you'll see something like:
php 12345 www-data 7w REG 252,1 48291 1234567 /var/www/myapp/storage/logs/laravel.log
Right after logrotate runs (without the postrotate fix), run it again:
php 12345 www-data 7w REG 252,1 48291 1234567 /var/www/myapp/storage/logs/laravel.log.1
Same inode number (1234567), different path. The process followed the file. That's your problem, right there in lsof output.
After octane:reload, re-run and you should see a new inode number pointing at laravel.log.
Supervisor Complicates This Slightly
Most single-VM setups run Octane under Supervisor. The reload command needs to run as the right user. If your Supervisor config runs Octane as www-data:
[program:octane]
command=php /var/www/myapp/artisan octane:start --server=swoole --port=8000
directory=/var/www/myapp
user=www-data
autostart=true
autorestart=true
Then the postrotate script in logrotate also needs to run as www-data, or at least have permission to signal the process. A cleaner approach:
postrotate
sudo -u www-data php /var/www/myapp/artisan octane:reload --no-ansi > /dev/null 2>&1 || true
endscript
Add the appropriate sudoers entry if you need it:
Defaults!octane_reload !requiretty
root ALL=(www-data) NOPASSWD: /usr/bin/php /var/www/myapp/artisan octane:reload *
This is one of those things that works fine until you audit your sudo rules and lock them down, and then 2 AM logging silently breaks again.
The Alternative: copytruncate
Logrotate has a copytruncate option that sidesteps the inode problem. Instead of renaming the file, it copies it and then truncates the original in place:
/var/www/myapp/storage/logs/laravel.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
The worker never loses its file handle because the path doesn't change. It keeps writing to laravel.log, which now points at the truncated (empty) file.
I'd steer clear of this for anything important. There's a race window between the copy and the truncate where you can lose log lines. For a low-volume internal app, probably fine. For anything in healthcare or e-commerce where you need an audit trail, don't risk it. Use the reload approach.
When I'd Reach For Each Setup
Single VM, FPM, no Octane: The default logrotate config mostly just works. I still add create and set proper ownership, but you don't need postrotate.
Single VM, Octane: Use the postrotate reload approach above. Switch to single log driver. Verify with lsof after your first manually-triggered rotation (logrotate -f /etc/logrotate.d/laravel).
Multiple VMs / containers: Log rotation is mostly not your problem anymore. Ship logs to stdout, let your container runtime or a sidecar handle it, and aggregate somewhere central. I use this pattern for anything on ECS or Kubernetes. Octane writing to a file in a container is already a smell.
High-volume single VM with tight SLAs: Consider a logging sidecar even on bare metal. Something like Promtail shipping to Loki, or Filebeat to Elasticsearch. Then you can rotate aggressively without worrying about the reload window.
One More Gotcha
If you're using octane:start with --watch in a staging environment (file watcher for auto-reload on code changes), the worker restarts on its own frequently enough that the stale handle problem often self-heals. This can mask the bug in staging and make it only visible in production where --watch is off. I've seen teams miss this for months.
Test your log rotation in an environment that matches production. Run logrotate -f manually, then write a log entry, then check which file it landed in.
The fix is two lines in a config file. The debugging, if you don't know what to look for, can eat a morning.
Need help shipping something like this? Get in touch.