Logrotate evaluates configuration when invoked, rotates eligible logs, and applies the selected retention and compression actions. A timer or another scheduler determines when that evaluation happens. The tool does not continuously cap file size, and renaming a log does not automatically redirect an application’s open descriptor. A working setup combines evaluation frequency, file policy, and the writer’s actual reopening behavior.

When it breaks

A size threshold can be exceeded between invocations, particularly during bursts or when the scheduler stops running. rotate limits retained generations, not elapsed history or all possible temporary disk use. Multiplying the threshold by the generation count therefore is not a hard capacity guarantee. The active file, growth between checks, and compression or copying work also consume space.

create specifies the replacement file’s mode and ownership; it does not signal the application. A postrotate hook can invoke a supported reopening mechanism, but HUP is not universally a reload request. delaycompress postpones compression by a rotation cycle; it does not fix a writer that never stops using an old inode.

Script failures can occur after file operations have already succeeded. A nonzero logrotate result is not a rollback of every rename or creation. Repeating forced rotation without inspecting the actual files and writer state can rotate again or discard needed generations. Diagnose the partial outcome instead of assuming that failure means nothing changed.

Check it quickly

Save this as logrotate-cli.py and run python3 logrotate-cli.py as root in a disposable Linux container with Python, logrotate, and gzip installed. The recorded run used Linux 6.12.76, Python 3.11.2, logrotate 3.21.0, and ext4. It uses a private configuration, private state files, and one logger child whose HUP handler explicitly reopens its own temporary log:

from pathlib import Path
from tempfile import TemporaryDirectory
import gzip,os,stat,subprocess,sys

worker=r'''
import os,signal,sys
path=sys.argv[1];fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_APPEND,0o600)
def reopen(signum,frame):
    global fd
    os.close(fd);fd=os.open(path,os.O_WRONLY|os.O_APPEND)
    print('reopened',flush=True)
signal.signal(signal.SIGHUP,reopen)
try:
    print('ready',flush=True)
    for line in sys.stdin:
        if line=='QUIT\n':break
        os.write(fd,line.encode());print('wrote',flush=True)
finally:os.close(fd)
'''
assert os.geteuid()==0,'use a disposable container for the root-owned fixture'
with TemporaryDirectory(prefix='xbcatm-logrotate-') as directory:
    root=Path(directory);active=root/'app.log';configuration=root/'logrotate.conf';state=root/'state'
    process=subprocess.Popen([sys.executable,'-c',worker,str(active)],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True)
    try:
        assert process.stdout.readline().strip()=='ready'
        configuration.write_text(f'''"{active}" {{
    size 64
    rotate 2
    compress
    delaycompress
    missingok
    notifempty
    create 0600 root root
    postrotate
        /bin/kill -HUP {process.pid}
    endscript
}}
''')
        def append(text):
            process.stdin.write(text+'\n');process.stdin.flush();assert process.stdout.readline().strip()=='wrote'
        def rotate(*options):return subprocess.run(['logrotate',*options,'--state',str(state),str(configuration)],capture_output=True,text=True,timeout=5)
        append('short');initial=active.read_bytes()
        result=rotate('--debug');assert result.returncode==0,result.stderr
        assert active.read_bytes()==initial and not state.exists() and not (root/'app.log.1').exists()
        result=rotate();assert result.returncode==0,result.stderr
        assert state.exists() and not (root/'app.log.1').exists()
        print('debug changes neither logs nor state; a normal below-threshold run records state without rotating')
        first=initial+b'first-'+b'a'*80+b'\n';second=b'second-'+b'b'*80+b'\n';third=b'third-'+b'c'*80+b'\n'
        for number,payload in enumerate([first,second,third],1):
            append((payload[len(initial):] if number==1 else payload).decode().rstrip('\n'))
            result=rotate();assert result.returncode==0,result.stderr
            assert process.stdout.readline().strip()=='reopened'
            assert active.read_bytes()==b'' and stat.S_IMODE(active.stat().st_mode)==0o600
            assert (root/'app.log.1').read_bytes()==payload
            if number>=2:
                with gzip.open(root/'app.log.2.gz','rb') as handle:assert handle.read()==(first if number==2 else second)
            assert not (root/'app.log.3.gz').exists()
        append('new-active');assert active.read_bytes()==b'new-active\n'
        print('three actual rotations reopen the writer, delay compression one generation, retain two archives, and preserve the expected bytes')
        failed=root/'failed.log';failed.write_bytes(b'failure-fixture')
        bad=root/'failed.conf';bad.write_text(f'''"{failed}" {{
    size 1
    rotate 1
    create 0600 root root
    postrotate
        /bin/false
    endscript
}}
''')
        result=subprocess.run(['logrotate','--state',str(root/'failed.state'),str(bad)],capture_output=True,text=True,timeout=5)
        assert result.returncode!=0
        assert (root/'failed.log.1').read_bytes()==b'failure-fixture' and failed.read_bytes()==b''
        print('a failed postrotate command returns failure after rename/create side effects have already happened')
        output,error=process.communicate('QUIT\n',timeout=5);assert process.returncode==0,error
    finally:
        if process.poll() is None:process.kill();process.wait()

The configuration selects a 64-byte size threshold, two generations, compression with one-cycle delay, and creation of a root-owned mode-0600 replacement. Its hook signals only the controlled child’s known PID. The logger acknowledges writes and reopening, letting the controller verify the handoff instead of inferring it from a successful signal command.

With a short log, debug mode changes neither log files nor the private state file. A normal invocation then creates state but does not rotate the below-threshold log. These checks distinguish configuration inspection from mutation and demonstrate that a successful invocation can legitimately perform no rotation.

The script next writes enough data to cross the threshold and invokes logrotate three times, adding a known payload before each invocation. Each rotation creates an empty mode-0600 active file, and the child acknowledges reopening it. The newest archive remains uncompressed as generation one, while the preceding generation is available as a gzip-compressed generation two.

The fixture decompresses the older archive and compares exact payload bytes. After the third rotation, generation two contains the second payload and generation one contains the third; no third compressed generation remains. A final record lands in the current active file. This verifies retention, compression, and writer placement together, not merely the appearance of expected filenames.

A second private configuration deliberately runs a failing postrotate command. Logrotate returns failure, but the old content is already in generation one and the new active file exists empty. This controlled failure exposes side effects that remain after the error. The fixture then stops and reaps its logger and removes all temporary files.

Operate the policy as a complete workflow

Use a state file consistent with the intended rotation job, and account for its locking when scheduling concurrent invocations. Separate state files are not a shared history. Debug mode is useful for reviewing decisions, while force mode changes eligibility and should not be mistaken for proof that the ordinary schedule will rotate at the intended time.

Choose directives according to the desired trigger. Size and time policies have specific interactions and ordering rules; combining familiar words does not automatically produce an AND condition. Verify the effective policy against representative small, large, empty, and missing files, and check the application identity can write the newly created file.

Monitor whether scheduled evaluations and reopening actually succeed, alongside filesystem capacity and the retained history window. If copytruncate is necessary, explicitly accept and test its copy-to-truncate loss window. Use independent log shipping or backups when required by the retention contract, and verify that representative old records remain readable after compression, rotation, and recovery.

Sources

  • logrotate 3.21.0 manual, size, rotate, create, compression, state, debugging, and script failures, checked 2026-09-12.
  • rename(2), why renaming a pathname does not redirect a live writer, checked 2026-09-12.
  • signal(7), application-defined HUP handling versus default termination, checked 2026-09-12.