Logrotate Not Rotating? Diagnose State and Permissions
Last edited on August 9, 2026

The Logrotate job ran, yet the log file stayed put. That result does not prove Logrotate is broken. It may have loaded a different rule, judged the file ineligible, read a recent state timestamp, rejected unsafe directory permissions, or rotated the path while the application kept writing to an old file descriptor.

A safe diagnosis separates those owners in order. You will first use read-only evidence, then reproduce the important decisions inside a disposable Debian lab. The lab proves that debug mode changes neither the log nor its state, shows a same-day state skip, triggers an insecure-parent failure, repairs it, and completes a normal non-forced rotation. Do not delete /var/lib/logrotate/status as a first fix.

Operators comfortable with shell commands and a small configuration stanza are the target readers. The reproduced environment was Debian 13.6 with Logrotate 3.22.0 on August 9, 2026. Ubuntu and RHEL-family paths can differ, so let logrotate --version identify the compiled default before assuming a state location.

Separate “Did Not Run” from “Ran and Skipped”

Begin with the scheduler. A perfect rule cannot rotate anything if its timer or cron entry never invoked Logrotate. On systemd hosts, inspect the timer, its last trigger, and the service journal. When ownership is unclear, compare systemd timers with cron before moving or duplicating the job.

systemctl list-timers --all logrotate.timer
systemctl status logrotate.timer logrotate.service --no-pager
journalctl -u logrotate.service --since '2 days ago' --no-pager

Some distributions still use cron, and custom installations may add another invocation. Cron execution logs can establish whether that path ran. Search schedules before creating a second job that shares the same state file:

sudo grep -Rns --fixed-strings 'logrotate' /etc/cron.d /etc/cron.daily /etc/cron.hourly /etc/crontab 2>/dev/null
ps -ef | grep '[l]ogrotate'

If the scheduler ran, switch the question from “Why did nothing happen?” to “Which decision made this file ineligible or unsafe?” The current manual states that --debug prints those decisions without changing logs or the state file. It also says separate Logrotate invocations can use separate --state files, which avoids one job borrowing another job’s history.

Read the Inputs That Decide Eligibility

Eligibility depends on configuration, file properties, time or size rules, recorded state, directory safety, and scripts. A quick cause map keeps force from becoming the first diagnostic tool.

Evidence What a healthy or expected result means Next owner when it fails
timer/cron history Logrotate actually started at the expected cadence scheduler or service unit
debug output lists the path an included rule matched the intended file configuration path, include order, or glob
Now and Last rotated state history is readable and temporally plausible state file, clock, or competing invocation
effective time/size sentence the log meets the rule that actually wins directive order, file size, or cadence
parent path is accepted ownership and mode meet Logrotate’s safety check directory owner/mode or deliberate su contract
postrotate and writer evidence the application reopened the newly created path hook, service signal, or application logging behavior

Time and size rules are not interchangeable

daily, weekly, and similar directives compare current time with recorded rotation history. size is mutually exclusive with a time interval, and the later directive takes precedence. minsize requires both the interval and minimum size; maxsize can allow an early rotation before the interval. These distinctions come from the current logrotate(8) manual, not from filename age alone.

Debug the main configuration so global options and include order remain visible:

sudo logrotate --debug /etc/logrotate.conf 2>&1 | sudo tee /tmp/logrotate-debug.txt
sudo grep -nE 'reading config|considering log|Now:|Last rotated|does not need rotating|error:' /tmp/logrotate-debug.txt

Debug mode does not run postrotate scripts. Rackspace’s Logrotate testing guidance makes that boundary explicit: use debug to inspect decisions, then test hooks only in a controlled window or disposable fixture.

State is history, not proof of successful application handoff

Inside that file, each state line tells Logrotate when it last accepted rotation for one path. It does not prove that an application reopened the new file or that downstream compression succeeded. First discover the actual default path and then read only the target entry:

logrotate --version
sudo grep -F '"/var/log/your-app/app.log"' /var/lib/logrotate/status /var/lib/logrotate/logrotate.status 2>/dev/null

Future-dated state deserves clock investigation before an edit. Investigate the timestamp with Voxfor’s clock-drift recovery path when system time moved, because changing only Logrotate history leaves every other time-dependent service exposed.

Build a Disposable Decision Lab

/tmp/voxfor-logrotate-lab-114 contains one synthetic log, a standalone rule, and an alternate state file. It never loads /etc/logrotate.conf or a production log. The opening guard refuses to reuse an existing directory.

set -euo pipefail
logrotate --version
sed -n '1,12p' /etc/os-release
date -u +%FT%TZ

Create the bounded fixture. delaycompress leaves the newest archive readable and compresses it on the following rotation, which lets the final receipt prove both generations.

set -euo pipefail
LAB=/tmp/voxfor-logrotate-lab-114
LOG="$LAB/app.log"
CONFIG="$LAB/app.logrotate"
STATE="$LAB/logrotate.state"
[[ ! -e "$LAB" ]]

sudo install -d -m 0750 "$LAB"
printf 'first-window\n' | sudo tee "$LOG" >/dev/null
sudo chmod 0640 "$LOG"
sudo tee "$CONFIG" >/dev/null <<EOF
$LOG {
    daily
    rotate 3
    compress
    delaycompress
    missingok
    notifempty
    create 0640 root root
}
EOF
sudo chmod 0644 "$CONFIG"

TODAY_STATE=$(date '+%Y-%-m-%-d-0:0:0')
printf 'logrotate state -- version 2\n"%s" %s\n' "$LOG" "$TODAY_STATE" | sudo tee "$STATE" >/dev/null
sudo chmod 0600 "$STATE"

Logrotate stores this calendar value as local civil time without a timezone marker, so both state generators intentionally use local date rather than date -u. UTC remains useful for the incident receipt, but using it inside the state line can select the wrong day near midnight on a non-UTC host. Used this way, the manual’s --state option is more than a testing convenience. Red Hat’s Logrotate debugging note warns that multiple scheduled instances should not share one state file because their histories and locks can conflict.

Prove Debug Mode Does Not Mutate the Fixture

Combining --debug with --force asks, “What would a forced rotation do?” while preserving both files and state. Hash all three inputs before and after; an empty diff is the acceptance result.

set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
LOG="$LAB/app.log"; CONFIG="$LAB/app.logrotate"; STATE="$LAB/logrotate.state"

sudo sha256sum "$LOG" "$CONFIG" "$STATE" | sudo tee "$LAB/debug-before.sha256" >/dev/null
sudo logrotate --debug --force --state "$STATE" "$CONFIG" 2>&1 | sudo tee "$LAB/debug-force.txt"
sudo sha256sum "$LOG" "$CONFIG" "$STATE" | sudo tee "$LAB/debug-after.sha256" >/dev/null
sudo diff -u "$LAB/debug-before.sha256" "$LAB/debug-after.sha256"
printf 'debug_no_mutation=PASS\n'

On production, that proof matters: debug output can reveal an error, but it cannot prove a postrotate script, compression command, signal, or application reopen behavior because none of those changes run.

Reproduce State Suppression Before Changing It

Force is appropriate inside this disposable fixture because retention and hooks are known. Run one forced baseline rotation, prove the archive contents, append a second line, and then run normally on the same day.

set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
LOG="$LAB/app.log"; CONFIG="$LAB/app.logrotate"; STATE="$LAB/logrotate.state"

sudo logrotate --verbose --force --state "$STATE" "$CONFIG"
sudo test -f "$LOG.1"
sudo test ! -s "$LOG"
sudo grep -qx 'first-window' "$LOG.1"
set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
LOG="$LAB/app.log"; CONFIG="$LAB/app.logrotate"; STATE="$LAB/logrotate.state"

printf 'second-window\n' | sudo tee -a "$LOG" >/dev/null
sudo logrotate --verbose --state "$STATE" "$CONFIG" 2>&1 | sudo tee "$LAB/same-day-skip.txt"
sudo grep -q 'log does not need rotating (log has already been rotated)' "$LAB/same-day-skip.txt"
sudo test ! -e "$LOG.2"
sudo test ! -e "$LOG.2.gz"

Nothing is corrupt in that result. daily plus a same-day state entry means the second non-empty log is not yet eligible. Better Stack’s Logrotate operations guide likewise puts status inspection ahead of guessing that the daemon ignored a file.

Make Permissions Fail, Then Repair the Owner

For a root-run rule, Logrotate rejects a parent path that an untrusted user can rewrite. The refusal prevents a writable-directory user from substituting files for a privileged rotation. Reproduce that guard, save its exit code, and restore the exact known mode.

set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
CONFIG="$LAB/app.logrotate"; STATE="$LAB/logrotate.state"

sudo chmod 0777 "$LAB"
set +e
sudo logrotate --verbose --force --state "$STATE" "$CONFIG" 2>&1 | sudo tee "$LAB/insecure-parent.txt"
INSECURE_RC=${PIPESTATUS[0]}
set -e
sudo chmod 0750 "$LAB"

test "$INSECURE_RC" -ne 0
sudo grep -qi 'parent directory has insecure permissions' "$LAB/insecure-parent.txt"
printf 'permission_guard=PASS exit_code=%s restored_mode=0750\n' "$INSECURE_RC"

Do not add su merely to silence the message. Use it when the log directory legitimately belongs to an application user and the chosen user/group can rename the old path and create the new one with intended ownership. Otherwise repair unexpected ownership or mode. Inspect every component with namei -l /var/log/your-app/app.log before changing it.

Now age only the lab’s state entry and run without force. A normal rotation proves that the repaired permissions and elapsed interval work together.

set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
LOG="$LAB/app.log"; CONFIG="$LAB/app.logrotate"; STATE="$LAB/logrotate.state"

YESTERDAY_STATE=$(date -d 'yesterday' '+%Y-%-m-%-d-0:0:0')
printf 'logrotate state -- version 2\n"%s" %s\n' "$LOG" "$YESTERDAY_STATE" | sudo tee "$STATE" >/dev/null
sudo chmod 0600 "$STATE"
sudo logrotate --verbose --state "$STATE" "$CONFIG"

Representative output from the reproduced run:

{
  "tested_at_utc": "2026-08-09T21:26:16Z",
  "environment": "Debian 13.6; logrotate 3.22.0",
  "state_calendar_timezone": "Pacific/Kiritimati (+1400); local date 2026-08-10 while UTC date was 2026-08-09",
  "debug_hashes_unchanged": true,
  "same_day_decision": "log does not need rotating (log has already been rotated)",
  "insecure_parent_exit_code": 1,
  "permission_error": "parent directory has insecure permissions",
  "ordinary_rotation_after_aging_test_state": true,
  "active_log_bytes": 0,
  "new_archive": "app.log.1 contains second-window",
  "older_archive": "app.log.2.gz contains first-window"
}

Apply the Same Evidence Order to Production

Find the exact rule and winning directives

Search for the literal log path across the main file and included directory. Read surrounding lines, global defaults before the include, later overrides, and duplicate matches. A glob that matches already rotated files can create its own cycle.

sudo grep -Rns --fixed-strings '/var/log/your-app/app.log' /etc/logrotate.conf /etc/logrotate.d 2>/dev/null
sudo logrotate --debug /etc/logrotate.conf 2>&1 | grep -A8 -B5 --fixed-strings '/var/log/your-app/app.log'

If debug shows a recent state time, compare it with date '+%F %T %z', the scheduler journal, and any second invocation. The state line is local civil time; keep UTC only as a separate incident timestamp. Back up the real state file before a proven targeted correction:

STATE_FILE=/var/lib/logrotate/status
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
sudo install -m 0600 "$STATE_FILE" "/var/lib/logrotate/status.before-$STAMP"
sudo grep -F '"/var/log/your-app/app.log"' "$STATE_FILE"

Treat the backup as a return path, not permission to edit blindly. Stop concurrent runs, preserve the header and every unrelated entry, change only a proven bad line, and immediately debug plus verify the affected rule. Never replace the whole file with an empty one to make every log look new.

Distinguish rename success from writer success

With the normal create path, Logrotate renames the active file and creates another at the original pathname. An application that keeps the old descriptor open may continue writing into the renamed or deleted inode. copytruncate avoids a reopen requirement but introduces a documented copy/truncate race in which log entries can be lost.

After a controlled rotation, compare the application PID’s file descriptors, new path owner/mode, archive growth, and a fresh synthetic application event. When df stays high after old logs were removed, use deleted-file disk diagnosis before truncating anything.

sudo lsof +L1
sudo stat -c '%n mode=%a owner=%U:%G bytes=%s' /var/log/your-app/app.log /var/log/your-app/app.log.1
sudo journalctl -u your-app.service --since '10 minutes ago' --no-pager

Container runtimes may own logs outside this mechanism. Docker’s json-file and local drivers require Docker-specific rotation controls; pointing a second Logrotate rule at daemon-owned active JSON files can corrupt assumptions about file lifecycle.

Test hooks only with a bounded acceptance event

Even with clean debug, a postrotate command can fail. During an approved window, run the real rule once only after confirming retention, hook effects, and rollback. Then write one identifiable application event and verify it lands in the new active file. Archive creation alone is not acceptance.

Verify Recovery and Preserve Rollback

Acceptance requires every central claim to be observable: unchanged debug hashes, state-based skip, permission rejection, normal rotation after targeted state aging, correct archive contents, correct active-file mode, and no remaining lab after cleanup.

set -euo pipefail
: "${LAB:=/tmp/voxfor-logrotate-lab-114}"
LOG="$LAB/app.log"; STATE="$LAB/logrotate.state"

sudo diff -u "$LAB/debug-before.sha256" "$LAB/debug-after.sha256"
sudo test -f "$LOG.1"
sudo test -f "$LOG.2.gz"
sudo test ! -s "$LOG"
sudo grep -qx 'second-window' "$LOG.1"
sudo gzip -cd "$LOG.2.gz" | grep -qx 'first-window'
sudo stat -c '%n mode=%a bytes=%s owner=%U:%G' "$LOG" "$LOG.1" "$LOG.2.gz" "$STATE"
printf 'verification=PASS debug=unchanged state=explained permission=repaired archives=verified\n'

Cleanup has one literal target and refuses any other value. It does not uninstall Logrotate or touch global configuration.

set -euo pipefail
LAB=/tmp/voxfor-logrotate-lab-114
[[ "$LAB" == /tmp/voxfor-logrotate-lab-114 && -d "$LAB" ]]
sudo find "$LAB" -depth -delete
test ! -e "$LAB"
printf 'cleanup=PASS path=%s absent=yes\n' "$LAB"

For production rollback, restore only the backed-up state file or rule that you intentionally changed, with the scheduler stopped and the original owner/mode preserved. A service reload or state restore should be followed by debug output, one controlled rotation when eligible, and one application-level write receipt.

FAQ: Logrotate State and Permission Failures

Why does Logrotate say “log does not need rotating”?

That message appears when the file does not meet its effective time/size rule or recorded state says the interval has not elapsed. Read Now, Last rotated, file size, and directive order together; filename age alone is insufficient.

Does logrotate --debug change logs or state?

No. Current Logrotate documents debug mode as non-mutating, and the reproduced lab confirmed identical SHA-256 values for log, configuration, and state before and after --debug --force. Debug also does not execute rotation hooks.

What is the difference between size, minsize, and maxsize?

size makes size the rotation criterion and is mutually exclusive with time intervals according to directive precedence. minsize requires the time interval and minimum size. maxsize permits rotation once the file is too large even before the time interval, when Logrotate next runs.

Is --force safe on production?

Force performs real rotation even when normal eligibility says no. It may run hooks, rename active files, compress archives, and remove history under the configured retention. Use it only after debug evidence, retention review, writer/hook planning, and a rollback window.

How do I know Logrotate actually ran?

Check logrotate.timer and logrotate.service history on systemd hosts or cron logs and schedule files elsewhere. Then correlate the invocation time with verbose/debug output and the target state entry; state alone does not prove the scheduler or application handoff succeeded.

Why does Logrotate reject insecure parent permissions?

Because root processes the path with elevated privileges, Logrotate refuses a parent directory writable by untrusted users. Restore intended ownership/mode or define a deliberate su user group contract that has exactly the file operations the application requires.

Should I delete the Logrotate state file?

No, not as a default repair. Deleting global state resets history for unrelated logs and can cause unexpected rotation or retention changes. Back up the file, prove one entry is wrong, stop competing invocations, and correct only that entry under a controlled recovery plan.

Close the Incident with Six Fields

Six fields close a useful incident record: scheduler evidence, matched rule, effective eligibility sentence, state time, permission/hook result, and application write acceptance. Preserve that record with the changed rule or state backup. For adjacent operations work, continue through Voxfor’s Linux guide library rather than carrying a one-off force command into the next incident.

Leave a Reply

Your email address will not be published. Required fields are marked *