Your app writes to /var/log/app.log. The disk fills up. You write a quick logrotate config, copy-paste from the first Stack Overflow result, set up a daily rotate. Two months later the disk is fine but you discover the app silently stopped logging at 3 AM on the day rotation kicked in. Or worse — the app crashed because logrotate renamed its log file and the open file descriptor stopped working.
Both of these are real, both are easy to avoid, and both are caused by people copying logrotate configs without understanding the difference between create, copytruncate, and the postrotate hook. Here is a config template that actually works for the kinds of apps you’ll encounter on a small server, plus the four rules that decide which logrotate strategy fits each one.
The two strategies, in one paragraph
createmode — logrotate renamesapp.logtoapp.log.1, then creates a new emptyapp.log, then signals the app (usually viapostrotate) to reopen its file descriptor. Loses zero log lines, but only works if the app responds to the signal.copytruncatemode — logrotate copiesapp.logtoapp.log.1, then truncates the original in place. The app keeps its file descriptor open and just keeps writing to the now-empty file. No signal needed, but there’s a race window during the copy where new log lines can be lost.
The decision rule is simple: if the app supports a “reopen log files” signal (almost all daemons do), use create. If you don’t know whether it does, or if it’s a small script writing to its own log, use copytruncate.
The config that works (create mode)
# /etc/logrotate.d/myapp
/var/log/myapp/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0640 myapp myapp
sharedscripts
postrotate
/bin/kill -USR1 $(cat /run/myapp.pid 2>/dev/null) 2>/dev/null || true
endscript
}Five lines in there carry weight, in order of how often I’ve seen them missed:
delaycompress— don’t gzip the just-rotated file on this run. Compress it on the next run instead. This matters because some apps still hold a file descriptor on the old file briefly during a graceful drain; gzipping it immediately can corrupt the writes.create 0640 myapp myapp— create the new file with explicit permissions and ownership. Skip this and the new file will be owned by root, and your app (running asmyapp) will fail to open it.sharedscripts— when the glob matches multiple files (app.log,error.log,access.log), run thepostrotateblock once at the end, not once per file. Without this, you signal the app three times and create a thundering-herd reopen.missingok notifempty— don’t error if the log file is missing yet (fresh app), don’t rotate empty files (an idle app shouldn’t generate empty .log.1 files).postrotate ... endscript— the bit that actually tells the app to reopen. The exact signal varies:SIGUSR1for nginx and many custom apps,SIGHUPfor syslog,kill -1for some Python apps that handle it.
The config that works (copytruncate mode)
# /etc/logrotate.d/random-script
/var/log/random-script/*.log {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}Notice what’s not there: no create, no postrotate, no signal. copytruncate handles all of it — at the cost of the small race window mentioned above. For a script writing 50 lines a minute, that race window is measured in milliseconds and almost certainly doesn’t matter. For an app writing thousands of lines per second under load, it does.
How to test it without waiting a day
sudo logrotate -d /etc/logrotate.d/myapp # debug — no actual rotation
sudo logrotate -f /etc/logrotate.d/myapp # force one rotation right nowThe -d mode is non-destructive — it tells you exactly what logrotate would do, including which files it’d rotate, what permissions it’d set, and what scripts it’d run. Always run this first when adding a new config.
The -f mode forces an immediate rotation. Run it, then check that:
ls -la /var/log/myapp/ # .log and .log.1 both exist
stat -c '%a %U:%G' /var/log/myapp/myapp.log # 640 myapp:myapp (matches create directive)
echo "test line" | sudo -u myapp tee -a /var/log/myapp/myapp.log
tail /var/log/myapp/myapp.log # the test line is thereIf the test line lands and the app’s process is still running and writing, you have a working rotation.
The four rules that decide which mode you need
- If the app comes with its own logrotate config under
/etc/logrotate.d/already (nginx, apache, postgres, mysql, php-fpm, syslog all do): leave it alone, don’t write a competing one. Just look at it for the canonical signal pattern for that app. - If the app documents a “reopen log file” signal: use
createmode +postrotatesending that signal. Look in the app’s docs for “log rotation” or “SIGUSR1”. - If the app is something you wrote in Python / Node / Go: write a SIGHUP handler that reopens the log file, then use
createmode. Adding the handler is 5 lines and beatscopytruncatefor correctness. - If the app is a third-party black box with no documented signal and no obvious way to handle one:
copytruncate. Accept the millisecond race; if it ever bites you, you’ll see it as a tiny gap in the log timeline and that’s a clear-enough signal to revisit.
One last gotcha: su in /etc/logrotate.conf
If logrotate refuses to rotate a file owned by a non-root user with the error “don’t log into world-writable directory, change permissions”, the fix is the su directive at the top of the per-file config:
/var/log/myapp/*.log {
su myapp myapp
daily
rotate 14
...
}This tells logrotate to drop privileges to myapp:myapp when manipulating these files, which is what you want for any log file owned by a non-root user. It’s a paranoid default that’s been in logrotate since 2014 and is usually the single line standing between a config that looks right and a config that actually works.
Photo: Archive cabinets with indexed annual volumes by Element5 on Pexels.
