logrotate Setup: A Hands-On Guide With Real Tests (2026)

logrotate Setup: A Hands-On Guide With Real Tests (2026)

Anyone looking into a logrotate setup has usually just had one of two experiences: a log file has grown to several gigabytes, or the disk filled up and a log was to blame. logrotate is the tool that has kept Linux logs in check for decades. It renames log files on a schedule, compresses them, and eventually deletes them. It ships with almost every distribution and runs quietly in the background. That is its biggest strength and also its biggest weakness: when a rule is wrong, you often find out too late.

We did not just copy the man page for this article. We looked at what actually happens on our own Ubuntu server (24.04, logrotate 3.21.0) and ran around 30 tests in a throwaway directory. The tests used programs writing a thousand lines per second, deliberate typos, wrong permissions and a real systemd service. Some results surprised us. A single typo, for example, made logrotate delete the log and still report success.

A circular conveyor belt in a dark server room carrying paper scrolls, the older ones being pressed into glowing cubes

If basic commands like ls, du or tail are still new to you, have a look at our Linux commands list first. And if your disk is already full right now, start with Linux disk full, which shows how to find the cause. This article is about making sure it does not happen again.

logrotate setup: the short version

If you just want to get one of your own log files under control, these five steps are enough:

  1. Create a file in /etc/logrotate.d/, for example /etc/logrotate.d/myapp, permissions 644, owned by root.
  2. Write a rule that says which file is rotated how often, how many old copies are kept, and whether they are compressed.
  3. Decide how the service learns about the switch. Either a signal or restart in postrotate, or copytruncate as a last resort.
  4. Dry-run it with sudo logrotate -d /etc/logrotate.d/myapp, then force one real run with sudo logrotate -v -f /etc/logrotate.d/myapp.
  5. Check that the service writes to the new file afterwards and not to the old one.

A solid template to start with:

/var/log/myapp/*.log {
    daily
    rotate 14
    missingok
    notifempty
    compress
    delaycompress
    create 0640 myapp adm
    sharedscripts
    postrotate
        systemctl reload myapp.service >/dev/null 2>&1 || true
    endscript
}

What each line means, and why steps 3 and 5 matter so much, is covered below. We reproduced every trap we describe ourselves.

What logrotate actually does

A program that logs appends line after line to a file. Left alone, that file grows until the disk is full. logrotate solves this with a simple idea: it renames the current file (app.log becomes app.log.1), shifts the older ones up by one (app.log.1 becomes app.log.2), optionally compresses them, and deletes the oldest once the allowed count is exceeded. Then it creates a fresh, empty app.log.

One thing to understand: logrotate is not a service that runs all the time. It is a program that starts, works through all rules and exits. Whether a file is “due” is decided from a state file that records the time of the last rotation for every log. On Ubuntu and Debian it lives at /var/lib/logrotate/status, and ours has 41 entries.

Five translucent glass containers in a row, each slightly smaller than the one before, with glowing liquid flowing from one to the next

When logrotate runs

On modern systems a systemd timer starts logrotate. On our server it looks like this:

$ systemctl list-timers logrotate.timer
NEXT                        LEFT LAST                        PASSED UNIT
Tue 2026-11-10 00:00:00 UTC  15h Mon 2026-11-09 00:00:00 UTC 8h ago logrotate.timer

The timer is set to OnCalendar=daily with Persistent=true: every night at midnight, and if the server was off at that moment, the run is caught up at the next boot. There is also the old script /etc/cron.daily/logrotate, but it exits immediately when systemd is running, so logrotate does not run twice. If cron is new to you, create a cron job covers the basics, including the cron.daily directories.

This leads to something many people miss: logrotate only checks once a day. If you set size 100M, the file is not rotated the moment it reaches 100 MB, but at the next run where it is larger than 100 MB. A service that writes 5 GB per day will still produce a 5 GB file. More on that below.

The main configuration

/etc/logrotate.conf sets defaults and includes the /etc/logrotate.d/ directory. Without comments, ours contains only this:

weekly
su root adm
rotate 4
create
include /etc/logrotate.d

/etc/logrotate.d/ holds 21 files on our server, nearly all shipped by packages: nginx, rsyslog, apt, fail2ban, certbot, mariadb, php8.3-fpm, ufw and more. In total logrotate considers 38 logs on every run. The rule for you as an admin: leave the package files alone and create separate files for your own services. Otherwise the next package update overwrites your changes or asks at every upgrade which version to keep.

The real trigger: a log nobody rotates

While preparing this article we searched our server for large log files:

$ sudo find /var/log -type f -size +20M -printf "%s %p\n" | sort -rn | head -3
297012020 /var/log/imguploader.log
154026061 /var/log/syslog.1
100663296 /var/log/journal/.../system@....journal

The largest log, almost 300 MB, belongs to one of our own small services. A search through /etc/logrotate.d/ showed that no rule covers this file. The service is a systemd unit with StandardOutput=append:/var/log/imguploader.log, so it writes every request as a JSON line to a file that has been growing for months. None of this produces a warning. The file just gets a little bigger every day.

That is the classic case where you have to set up logrotate yourself: your own service, your own script, a Docker container with a bind mount, or a cron job with >> /var/log/something.log. Packages bring their rules with them. Anything you build yourself brings nothing.

We did not touch the production file for this article. All tests below ran in a throwaway directory under /tmp with separate state files (-s), so logrotate’s real state stayed untouched. We wrote the rule for the service afterwards, using what the tests taught us.

Writing your own rule, line by line

A rule consists of one or more paths and a block in curly braces. The most important directives:

DirectiveWhat it does
daily / weekly / monthlyHow often to rotate, measured from the last rotation
rotate 14How many old copies to keep. 0 means none
size 100MRotate once larger than 100 MB, regardless of schedule
maxsize 100MRotate on schedule or earlier if larger than 100 MB
minsize 1MRotate on schedule, but only if at least 1 MB
missingokNo error if the file is missing
notifemptyDo not rotate empty files
compressCompress old copies (default: gzip)
delaycompressDo not compress the newest old copy (.1) yet
create 0640 user groupAfter renaming, create a new empty file with these permissions
copytruncateInstead of renaming: copy, then truncate the original to 0
dateextDate instead of number as suffix (app.log-20261109)
maxage 30Delete old copies older than 30 days
su user groupRotate as this user (needed for insecure directory permissions)
sharedscriptsRun scripts once, even if the pattern matches many files
postrotate … endscriptShell commands after rotation, usually to notify the service

delaycompress looks redundant, but it matters. Some services write a few more lines to the old file after it has been renamed, until they have processed the signal. If logrotate compressed it immediately, those lines would be lost or the compressed file would be incomplete. With delaycompress, app.log.1 only becomes app.log.2.gz on the next run.

The key decision: how does the service learn about the switch?

This is where most logrotate problems come from, so we tested this part most thoroughly.

When a program opens a log file, the operating system gives it a file descriptor. That descriptor does not point to the file’s name, but to the file itself. When logrotate renames app.log to app.log.1, the program notices nothing. It keeps writing to the same file, which now simply has a different name.

Test 1: create without notification

We started a small Python program that writes a numbered line to svc.log every millisecond and keeps the file open, like almost every service does. Then we rotated using only create, without postrotate. Two seconds later:

-rw-r----- 1 root root     0 svc.log
-rw-r--r-- 1 root root 92143 svc.log.1

$ ls -l /proc/<PID>/fd | grep svc
3 -> /tmp/gmlr/logs/svc.log.1

The new svc.log stays empty. The program happily keeps writing to svc.log.1. At the next run svc.log.1 becomes svc.log.2, and the program keeps writing there. With compress it gets worse: logrotate compresses a file that is still being written to and then deletes the original. The service is now writing to a deleted file. That is exactly how you end up with the classic case where df reports a full disk but du cannot find anything.

Test 2: copytruncate

For programs that cannot be notified there is copytruncate. logrotate copies the content to app.log.1 and truncates the original to zero bytes. The program simply keeps writing to its file, which is now small again. Sounds elegant. The man page warns, though, that there is a small window between copying and truncating in which lines can be lost.

We wanted to know how small that window is in practice. Using the same program (one line per millisecond), we ran eight rotations in a row and then counted which line numbers were missing:

1858 lines, missing 1
1861 lines, missing 1
1859 lines, missing 1
1860 lines, missing 1
1862 lines, missing 1
1857 lines, missing 1
1862 lines, missing 1
1861 lines, missing 1

Exactly one line was lost in eight out of eight runs, and two in our very first attempt. At one line per millisecond that is a small window, but it is not theoretical. It happens every time. A service that writes a lot loses lines at every rotation, and specifically the ones around midnight. For an access log that usually does not matter. For an audit log or a payment log it does.

A glowing stream of droplets poured into a glass while the glass is being emptied into another one, a few droplets falling between them

Test 3: copytruncate and programs without append mode

There is a second copytruncate trap that is mentioned less often. It depends on how the program opened the file. If it opened it in append mode (O_APPEND, in Python open(..., 'a'), in the shell >>), it automatically jumps to the new end of file after truncation. If it opened it in normal write mode, it remembers its position. After truncation it keeps writing at the old offset, for example from byte 17,000, leaving a hole in front.

That is exactly what we measured. A program that opened the file without append, one rotation with copytruncate, then:

$ stat -c %s w2.log
26833
$ tr -cd '\000' < w2.log | wc -c
17473
$ du -k w2.log
12

17,473 of the 26,833 bytes are null bytes. According to ls the file is 26 KB, but du says it only uses 12 KB on disk, because the hole is not stored. Open such a file in less and you see a long row of ^@ at the start, while grep reports “Binary file matches” instead of the lines you were looking for. Log shippers choke on it as well.

A systemd service with StandardOutput=append: does not have this problem. We reproduced that case with a throwaway unit: no null bytes, but again one line was missing after the rotation. You can check the mode of a running process yourself, by the way. /proc/<PID>/fdinfo/<FD> contains a flags: line. If the bit for O_APPEND (octal 02000) is set, the process appends. For our real service it reads 02102001, so it appends.

Test 4: create with a signal in postrotate

The clean way: after the rename, the program receives a signal and reopens its log file. We taught our test program to close and reopen its file on SIGHUP and rotated it like this:

/tmp/gmlr/logs/s.log {
  rotate 3
  create 0644 root root
  postrotate
    kill -HUP $(cat /tmp/gmlr/w3.pid)
  endscript
}

Result across three runs: 0 missing lines, and afterwards the file descriptor points to the new s.log. The few lines written between the rename and the signal end up complete in s.log.1. That is exactly why delaycompress exists.

A small lighthouse sending a beam of light to a machine with gears, which turns towards a new empty jar

Test 5: the wrong signal kills the service

Now for the catch. We applied the same rule with systemctl kill -s HUP to a real systemd service whose program has no handler for SIGHUP. Result:

$ systemctl is-active gmlr-test
inactive

The service was dead. The default action for SIGHUP on Linux is to terminate the process. So if you copy kill -HUP from a forum without checking that the program understands it, you shut your service down every night at midnight. Whether it comes back depends entirely on Restart= in the unit.

The right signal is in the program’s documentation:

  • nginx: USR1 reopens the logs. The package does this via invoke-rc.d nginx rotate.
  • Apache: systemctl reload apache2 or apachectl graceful.
  • rsyslog: ships its own script (/usr/lib/rsyslog/rsyslog-rotate).
  • PHP-FPM: USR1 reopens the logs.
  • MySQL/MariaDB: FLUSH LOGS or mysqladmin flush-logs.
  • Your own Node, Python or Go services: usually nothing, unless you built it in.

For your own services without a signal handler there are two honest options: run systemctl restart in postrotate (we tested this, the service then writes cleanly to the new file, but it is briefly unavailable), or deliberately use copytruncate and accept losing the odd line. A third option is often better: the service does not write to a file at all but to stdout, and systemd hands that to journald. Then journald enforces the limit via SystemMaxUse, and logrotate is no longer needed for that service. Create a systemd service shows how to write such a unit.

The Better Stack video shows the basic structure of a rule and the difference between create and copytruncate on screen. If you prefer watching to reading, it gives a good overview in a short time.

Testing rules safely

logrotate has two switches you need to know before letting a rule loose on a production server.

-d (debug): shows what logrotate would do and does nothing. Ideal for checking syntax and paths:

sudo logrotate -d /etc/logrotate.d/myapp

-f (force): rotates immediately, even if the schedule has not been reached yet. Combined with -v you see every step:

sudo logrotate -v -f /etc/logrotate.d/myapp

A third switch is worth its weight in gold for experiments: -s (custom state file). Your test then does not change the real state in /var/lib/logrotate/status. All our tests ran this way.

The first run does not rotate

One observation that confuses many people on their first test: we ran a fresh rule with daily and a new state file, without -f. Result:

Creating new state
  Last rotated at 2026-11-09 08:00
  log does not need rotating (log has already been rotated)

On first contact with a file, logrotate just records it in the state file and acts as if it had just been rotated. The first real rotation therefore happens a day (or a week, a month) later at the earliest. That is intentional, not a bug. But if you check right after setting it up and see “nothing happens”, you will probably look in the wrong place. For an immediate test you need -f.

Without create the file disappears

In the same test we had left out create. After -f, only app.log.1.gz was left in the directory. There was no app.log any more. On Ubuntu, the global /etc/logrotate.conf does contain create, and that also applies to files in logrotate.d/. But if you start your own configuration without that main file, for example in a container or with logrotate my.conf, you do not get that default. Whether a service recreates the file itself varies from program to program.

Traps we triggered ourselves

Apart from the switching problem there are a handful of configuration mistakes that are particularly nasty, because logrotate barely complains about them. We built every one of them on purpose.

A typo deletes the log, exit code 0

We wrote a rule with two typos, the kind that happen at three in the morning:

/tmp/gmlr/logs/a.log {
 rotat 2
 dayly
}

logrotate reports both lines as unknown option -- ignoring line and carries on. Without a rotate line, however, the default applies, and the default is zero. The verbose output then says:

rotating pattern: /tmp/gmlr/logs/a.log  forced from command line (no old logs will be kept)
...
removing old log /tmp/gmlr/logs/a.log.1

Afterwards the directory was empty. The log was renamed and immediately deleted, and no new one was created. Exit code: 0. If you only monitor logrotate through the exit code or systemctl status, you see a successful run while the data is gone. So after every new rule, run -d once and actually read the output. If it says “unknown option” or “no old logs will be kept”, something is wrong.

On Ubuntu the global rotate 4 from /etc/logrotate.conf usually saves you here, because files in logrotate.d/ inherit the defaults from it. Our test deliberately ran without the main file. Do not rely on it, though: on other systems, in containers or with your own invocations, that safety net is missing.

Permissions too open: the rule is silently ignored

A configuration file with permissions 0666 (writable by everyone):

warning: Potentially dangerous mode on t8.conf: 0666
error: Ignoring t8.conf because it is writable by group or others.

The rule is skipped entirely. That is correct for security reasons, because logrotate runs as root and executes the scripts in the file. But: exit code 0. An error appears in the journal, nothing else happens, and the log keeps growing. Always create new files in /etc/logrotate.d/ with 644 and owned by root.

Insecure directory permissions: su is missing

If a log lives in a directory that is writable by a group other than root (in our case group adm, permissions 775), logrotate refuses to work:

error: skipping "/tmp/gmlr/unsafe/u.log" because parent directory has insecure
permissions (It's world writable or writable by group which is not "root")
Set "su" directive in config file to tell logrotate which user/group should be
used for rotation.

At least the exit code is 1 here. The fix is in the message: add su root adm to the rule (or whichever user owns the directory). After that the rotation worked. Interestingly, with the same permissions but the directory owned by group root, there was no complaint.

/path/* instead of /path/*.log

A pattern like /var/log/myapp/* also matches files that have already been rotated. In our test, names like a.log.1.1 and b.log.1.1 appeared after two runs. With compression this becomes app.log.1.gz.1.gz, and the number of files explodes. Always restrict the pattern to the extension: /var/log/myapp/*.log.

dateext and two rotations on the same day

dateext is popular because app.log-20261109 is more readable than app.log.3. But if you rotate twice on the same day (through size or a manual -f), this happens:

error: destination /tmp/gmlr/logs/d.log-20261109 already exists, skipping rotation

The second rotation is skipped. The fix is a date format that includes the time, for example dateformat -%Y%m%d-%H%M%S. With that, a second rotation one second later worked without errors in our test.

A failing postrotate stops nothing

A postrotate script that exits with an error (we used exit 3) leads to error running non-shared postrotate script and exit code 1. But the rotation itself has already happened at that point. The file has been renamed and the new one created. If the script was supposed to notify the service, the service is now writing to the old file, see test 1. Commands in postrotate should therefore be robust and make failures visible, rather than swallowing them completely with || true when the notification really matters.

Missing file without missingok

Without missingok, a rule for a missing file ends with stat of ... failed: No such file or directory and exit code 1. That is correct, but it can get annoying when a service only logs occasionally. Rule of thumb: use missingok for services that do not always run. Leave it out when a missing file is a warning sign.

Rotating by size: size, maxsize, minsize

Three directives that sound alike and behave quite differently. We tested each with a 2.7 MB file and a 1 MB limit:

  • size 1M replaces the schedule completely. Combined with daily, the file was still only judged by size. daily had practically no effect.
  • maxsize 1M keeps the schedule and also rotates early if the limit is exceeded. In our test the same file was rotated twice on the same day, because it was over 1 MB again at the second run. This is usually what you actually want.
  • minsize 1M rotates on schedule, but only if the file is large enough. Good for logs that grow slowly and would otherwise be rotated daily as tiny files.

All three are only checked when logrotate runs, which is once a day. If a log really grows fast, you can add an extra timer that runs logrotate hourly for just that file:

# /etc/systemd/system/logrotate-myapp.timer
[Timer]
OnCalendar=hourly
Persistent=true

[Install]
WantedBy=timers.target

Add a matching .service file with ExecStart=/usr/sbin/logrotate /etc/logrotate.d/myapp. Note that the rule then applies twice, once in the hourly run and once in the nightly one. With maxsize that is not a problem. If you want to keep the file out of the main configuration, place it in a separate path such as /etc/logrotate-hourly.d/.

In this tutorial Waldirio Pinheiro sets up logrotate step by step and also shows the test runs with -d and -f in the terminal.

Compression: gzip, xz or zstd?

By default logrotate compresses with gzip. With compresscmd, compressext and compressoptions you can use other programs. We compressed the first 50 MB of our real syslog with the usual candidates:

MethodResultTime
gzip -6 (default)6.5 MB0.58 s
gzip -96.2 MB1.05 s
zstd -35.8 MB0.12 s
xz -63.7 MB10.5 s
zstd -193.7 MB24.4 s

Logs compress extremely well. gzip already achieves almost 8 to 1. zstd at its default level is nearly five times faster than gzip and slightly smaller. xz and zstd at the highest level halve the size again, but cost 20 to 40 times as much time. For most servers the default is perfectly fine. If you produce many gigabytes of logs a day, zstd is a good choice:

compress
compresscmd /usr/bin/zstd
compressext .zst
compressoptions -3 -T0
uncompresscmd /usr/bin/unzstd

Keep in mind when switching that zgrep and zless only work with gzip. For zstd there are zstdgrep and zstdless.

How many old logs should you keep?

This question is less technical than organisational. A few pointers:

  • For troubleshooting, 7 to 14 days are usually enough. Problems older than that are rarely still worth investigating through logs.
  • For access logs containing IP addresses, GDPR applies in the EU. Many data protection officers consider 7 to 14 days reasonable when the logs serve security purposes. Anything longer needs a justification. This is not legal advice, but it is a good reason not to copy rotate 365 from a forum.
  • For audit and accounting data, log files are usually the wrong tool. Anything that must be kept for years belongs in a database or an archive with backups.

It also matters that rotate counts, it does not calculate. weekly with rotate 4 keeps four weeks. daily with rotate 4 keeps four days. If you also rotate by size, you may get several copies per day, and then rotate 14 is no longer a two-week window. That is what maxage is for: it deletes old copies by age, no matter how many there are.

Our finished rule for the real case

Back to the 300 MB log from the beginning. Our service is a systemd unit with StandardOutput=append:, it has no signal handler, and it may restart briefly at night. The tests lead to this rule:

# /etc/logrotate.d/imguploader
/var/log/imguploader.log {
    daily
    maxsize 50M
    rotate 14
    maxage 30
    missingok
    notifempty
    compress
    delaycompress
    create 0640 root adm
    postrotate
        systemctl restart imguploader.service
    endscript
}

Why this way: create plus restart, because copytruncate measurably loses lines and HUP would kill the service. maxsize 50M so that a bug in a loop cannot still produce gigabytes. maxage 30 as a second limit in case several rotations per day do happen. And before activating it: sudo logrotate -d /etc/logrotate.d/imguploader, and read the output.

The better long-term solution would be to let the service write to the journal instead of a file. Then journald limits the size and there is one less rule to maintain.

Monitoring logrotate

Because many errors are silent, it pays to check now and then that logrotate is actually working:

# Did the last run finish without errors?
journalctl -u logrotate --since "-3 days"

# When was each log last rotated?
sudo cat /var/lib/logrotate/status

# Which large logs are not covered by any rule?
sudo find /var/log -type f -size +100M

On our server, for example, the state file showed that the nginx logs had not been rotated since February. Not an error: we use Caddy now, the nginx logs are empty, and notifempty skips empty files. But these are exactly the spots you should understand once before you trust the output.

If you want to watch your server permanently, put an alert on disk usage rather than on logrotate itself. We describe how in server monitoring. A full disk is the symptom that eventually makes every logrotate mistake visible.

Two quirks you should know about

The systemd unit is sandboxed. On Ubuntu 24.04, logrotate.service runs with PrivateTmp=true and ProtectSystem=full. That means a nightly run sees its own empty /tmp and may not write to /usr, /boot or /etc. A rule for a file under /tmp therefore does nothing in the nightly run, even though it works when you test manually with sudo logrotate. We derived this from the unit file and did not reproduce it separately. Logs belong in /var/log anyway.

Docker containers are a separate topic. Whatever a container writes to stdout does not go to logrotate but to Docker’s logging driver. You limit it with max-size and max-file in /etc/docker/daemon.json or per container in your compose file. You only need logrotate for files a container writes to the host through a volume.

A tidy archive room with shelves full of small glowing cubes, a robotic arm placing the newest cube on the shelf

Frequently asked questions about logrotate setup

How do I set up logrotate for my own log file? Create a file in /etc/logrotate.d/ (permissions 644, owned by root), add the path and a block with daily, rotate, compress, missingok, notifempty and create, and decide how the service gets notified. Then check it with sudo logrotate -d and test it once for real with sudo logrotate -v -f.

How do I test logrotate without breaking anything? With -d, logrotate only shows what it would do. With -s /tmp/teststate it uses its own state file, so your test does not change the real state. For real experiments, a throwaway directory with test files is the safest option.

Why does logrotate not rotate my file? The most common reasons: the rule was just created (the first run only records the file), the file is empty and notifempty is set, the configuration file has permissions that are too open and is ignored, or the directory has insecure permissions and su is missing. sudo logrotate -d /etc/logrotate.d/file shows the reason in almost every case.

What is the difference between create and copytruncate? With create, the file is renamed and a new one is created. The service must be notified afterwards, otherwise it keeps writing to the old file. With copytruncate, the file is copied and the original emptied. The service notices nothing, but lines are lost between copying and emptying. In our tests that was at least one line on every single rotation.

Why does my service write to the .1 file after rotation? Because it keeps the file open and does not learn about the rename. You need the right signal or a restart in postrotate. You can check with ls -l /proc/<PID>/fd. If an entry points to .1, the notification is missing.

Can I just put kill -HUP in postrotate? Only if the program understands SIGHUP. For programs without a matching handler, SIGHUP terminates the process. In our test the systemd service was inactive afterwards. Check the documentation for the signal the program expects, or use a restart.

How often does logrotate run? On Ubuntu and Debian once a day via the systemd timer logrotate.timer at midnight. Size rules like size and maxsize are only checked during that run. For logs that grow faster you can add your own hourly timer.

Where can I see when a log was last rotated? In the state file /var/lib/logrotate/status. It lists the date and time of the last rotation for every file logrotate knows. You can find errors of a run with journalctl -u logrotate.

Do I need logrotate if I use journald? Not for anything that goes into the journal. journald limits itself via SystemMaxUse in /etc/systemd/journald.conf. You need logrotate for classic log files that programs write directly to disk, such as web servers, databases or your own scripts.

Which compression is best for logs? gzip is the default and perfectly adequate for most servers. On our server it turned 50 MB of syslog into 6.5 MB in 0.6 seconds. zstd was almost five times faster and slightly smaller. xz saves even more space but takes about 18 times as long.

Conclusion: a logrotate setup takes five minutes, checking it is the part that matters

logrotate is not a complicated tool. A rule is quickly written, and the template at the start of this article covers most cases. The real work lies in two questions no template answers for you: how does my service learn about the switch? And did logrotate really understand my rule the way I meant it?

Our tests showed why both questions matter. copytruncate lost lines on every rotation and produced files full of null bytes for programs without append mode. A HUP sent to the wrong program terminated the service. A typo deleted the log, and a file with overly open permissions was ignored, both with exit code 0. None of this stands out as long as you only look at “run successful”.

So our advice: read every new rule once with -d, run it once for real with -f, then check with ls -l /proc/<PID>/fd where the service is writing. And run find /var/log -size +100M every now and then. That is how we found our own 300 MB log that nobody had rotated for months.