Linux Log Rotation with logrotate — Before Your Disk Fills Up
logrotate for your own apps on Ubuntu/Debian: a working config, create vs copytruncate, postrotate reloads, maxsize, testing with -d, common traps.
Distro packages ship their own logrotate configs, so /var/log/syslog and nginx logs rotate without you doing anything. The problem is everything you install: the app in /opt, the game server writing server.log into its own directory, the cron script appending to /var/log/whatever.log. Nobody rotates those, and one day the disk is full of a single 30 GB text file.
TL;DR: one file per app in /etc/logrotate.d/, use create + a postrotate reload if the app can reopen its log, copytruncate if it can't, test with logrotate -d, and remember logrotate only runs once a day by default.
sudo tee /etc/logrotate.d/myapp >/dev/null <<'EOF'
/var/log/myapp/*.log {
daily
rotate 14
maxsize 200M
missingok
notifempty
compress
delaycompress
dateext
create 0640 myapp adm
sharedscripts
postrotate
systemctl kill -s HUP myapp.service >/dev/null 2>&1 || true
endscript
}
EOF
sudo logrotate -d /etc/logrotate.d/myapp # dry run, explains what it would do
sudo logrotate -vf /etc/logrotate.d/myapp # force a real rotation once
How it actually runs
logrotate isn't a daemon. On Ubuntu 24.04 and Debian 12/13 it's started by a systemd timer, once a day:
systemctl list-timers logrotate.timer
systemctl cat logrotate.service
There's still a /etc/cron.daily/logrotate script on some installs, but it exits early when systemd is running — the timer is what counts. Each run reads /etc/logrotate.conf, which includes everything in /etc/logrotate.d/, and checks each log against its rules. When a log was last rotated is tracked in /var/lib/logrotate/status.
That last part matters: daily doesn't mean "at midnight", it means "if the last rotation of this file was at least a day ago when the timer fires".
The config, line by line
/var/log/myapp/*.log— which files. Use*.log, not*— see the gotcha below.daily/weekly/monthly— rotation interval.rotate 14— keep 14 old files, delete older ones. Withdailythat's two weeks.maxsize 200M— rotate early if the file is bigger than this, even if the interval hasn't passed. Only checked when logrotate runs, though.missingok— don't error if the log doesn't exist (yet).notifempty— don't rotate empty files.compress+delaycompress— gzip old logs, but leave the most recent rotated one (.1) uncompressed. Useful when the app might still write a few lines to the old file right after rotation, and handy forlesswithoutzless.dateext— rotated files get a date suffix (app.log-20260401) instead of.1,.2... I like this because file names don't shift every day and backup tools don't re-copy everything.create 0640 myapp adm— after moving the old log away, create a fresh empty one with these permissions and owner.sharedscripts— runpostrotateonce for the whole glob, not once per matched file.postrotate ... endscript— tell the app to reopen its log file.
create vs copytruncate
This is the one decision that matters, and it's about how the app holds its log file.
create (default): logrotate renames app.log to app.log-20260401 and creates a new empty app.log. But the running app still has the old file open — it keeps writing into the renamed file, because a file handle points at the inode, not the name. That's why you need postrotate to make the app reopen its log: SIGHUP, SIGUSR1, a reload command, whatever the app supports. nginx uses USR1 (its shipped config does invoke-rc.d nginx rotate), many daemons reopen logs on HUP. Check your app's docs — sending HUP to something that interprets it as "terminate" is a bad morning.
copytruncate: logrotate copies app.log to the rotated name, then truncates the original to zero. The app keeps writing to the same inode and never notices. Use it for apps that can't reopen logs: most Java game servers, random binaries, scripts with >> file.log.
The price of copytruncate:
- Lines written between the copy and the truncate are lost. Usually a few milliseconds' worth, but it's not zero.
- The file is written twice during rotation, which hurts with multi-GB logs.
If you're writing your own tool that tails logs, this is also why you watch the inode: with create the inode changes and you have to reopen; with copytruncate it stays the same but the size suddenly drops. The Discord alert bot post deals with exactly that.
Gotcha: copytruncate and apps that don't append
If the app opened its log without O_APPEND (plain "open for writing" and remember the offset), copytruncate truncates the file to 0, and the app's next write lands at its old offset — say 4 GB in. The file is now "4 GB" again, but it's a sparse file full of null bytes. ls -lh shows the full size, du -h shows almost nothing, and less shows a wall of ^@.
Check with:
ls -lh /var/log/myapp/app.log
du -h /var/log/myapp/app.log
If they differ wildly after a rotation, that's it. Fix: make the app open in append mode (for shell redirects: >> not >), or switch to create with a reload, or have systemd capture the output into the journal instead.
Gotcha: the glob catches already-rotated files
/var/log/myapp/* {
matches app.log, but also app.log-20260401.gz, which then gets rotated again into app.log-20260401.gz-20260402.gz, and so on. Always end the pattern with the actual log extension: *.log.
Gotcha: "insecure permissions" and skipped configs
Two errors that make logrotate silently skip your config (you'll only see them in journalctl -u logrotate):
error: skipping "/opt/game/logs/server.log" because parent directory has insecure permissions
(It's world writable or writable by group which is not "root")
The log directory is writable by a non-root group, and logrotate refuses to work there as root (symlink attacks). Fix with the su directive so it runs as the owner:
/opt/game/logs/*.log {
su game game
...
}
The other one: the config file in /etc/logrotate.d/ itself must be owned by root and not group/world writable, otherwise it's ignored with a "bad file mode" message. If you created it with an editor running as another user, sudo chown root:root and chmod 644 it.
Gotcha: size-based rotation needs more frequent runs
size 100M and maxsize 100M are only evaluated when logrotate runs. With the default daily timer, a log that grows 50 GB per day will still reach 50 GB. For chatty logs, run logrotate hourly:
sudo systemctl edit logrotate.timer
[Timer]
OnCalendar=
OnCalendar=hourly
The empty OnCalendar= clears the default before setting the new one — without it you get both. Then daily rules still rotate once a day (the status file tracks that), but maxsize gets checked every hour. If you're new to timer overrides, systemd timers explains how they work.
Testing without breaking things
# dry run: parses config, shows what would happen, changes nothing
sudo logrotate -d /etc/logrotate.d/myapp
# force rotation now, verbose
sudo logrotate -vf /etc/logrotate.d/myapp
# check the full config parses (all files)
sudo logrotate -d /etc/logrotate.conf 2>&1 | grep -i error
-d implies no changes, so it's safe on production. Note that -f on a single file still uses the global state file, so forcing it once is harmless — it just records a rotation.
What logrotate doesn't handle
- The systemd journal. journald rotates itself; limit it with
SystemMaxUse=injournald.conf(details in disk-full forensics). - Docker container logs. Docker owns those files; set
max-size/max-fileindaemon.json— see installing Docker. - Apps that rotate themselves. Many Java apps (log4j, logback) roll their own files. Don't add logrotate on top — two rotators fighting over the same file is a mess. Configure retention in the app instead.
For anything else that writes plain text files: one small config in /etc/logrotate.d/, run -d once, done.