Reclaim disk space on a Linux server when a filesystem crosses a threshold — and not a moment before.
$ diskwarden --mountpoint /
2026-10-06T09:12:03Z [INFO] 62% of / used, below 70% — nothing to do
$ diskwarden
2026-10-06T14:40:11Z [INFO] === ROUTINE — 74% used, free 21GiB, DRY RUN ===
2026-10-06T14:40:11Z [INFO] would remove 1184 file(s), 6.1GiB — archived logs older than 5d
2026-10-06T14:40:12Z [INFO] would remove 61 file(s), 2.4GiB — rotated logs older than 7d
2026-10-06T14:40:12Z [INFO] would run: journalctl --vacuum-time=5d
2026-10-06T14:40:12Z [INFO] would run: docker image prune (older than 5d)
2026-10-06T14:40:12Z [INFO] === dry run complete — nothing was modified. Re-run with --apply ===
logrotate rotates configured log files on a schedule. diskwarden watches a filesystem's usage percentage and acts only when it has to, across everything that quietly fills a server:
| logrotate | diskwarden | |
|---|---|---|
| trigger | time, or a size you configured per file | the filesystem crossing a threshold |
| scope | log files you listed | logs, journal, package cache, /tmp, crash dumps, container images and build caches |
| active files | rotates them, carefully | leaves them alone — until an emergency threshold, where it truncates |
| unknown files | ignores them | *.log.<suffix> anywhere under /var/log is a rotated copy, no config needed |
They are complements, not alternatives. Keep logrotate. diskwarden is for the night it was not enough.
< 70 % nothing happens. No logging, no work, no risk.
70 – 79 % ROUTINE
archived and rotated logs, journal vacuum, package cache,
old /tmp files, crash dumps, orphaned container images,
CI runner cache volumes
≥ 80 % ROUTINE, and then:
truncate active log files over 300 MB
truncate container JSON logs over 100 MB
Both numbers are configurable. The gap between them matters: routine work is safe enough to run hourly and unattended, truncating a file somebody is writing to is not, and the two should not share a trigger.
Docs: https://allan-nava.github.io/diskwarden/ · Plan: BACKLOG.md → ROADMAP.md — generated from the script and the role defaults, so it cannot document a default that does not exist.
sudo install -m 0755 diskwarden /usr/local/sbin/diskwarden
sudo install -m 0644 diskwarden.conf.example /etc/diskwarden.conf
sudo $EDITOR /etc/diskwarden.conf
# Look at what it would do, on the real machine, before anything is scheduled.
sudo diskwarden
# Only when you agree with the plan:
sudo install -m 0644 systemd/diskwarden.* /etc/systemd/system/
sudo systemctl enable --now diskwarden.timer--apply lives in the systemd unit and nowhere else. There is no config key
that turns deletion on, and no default that deletes. The only way diskwarden
removes a file is a unit you installed on purpose.
Truncate active files, never delete them. A process holding a log open
keeps writing to the inode after you delete it, so the space is not returned
until it restarts. df stays full while du looks fine, and only
lsof +L1 explains why. Deleting a 40 GB log that a running service owns is
how you turn a disk alert into an outage.
An active log ends in .log; anything with a suffix after it is a copy.
app.log is being written. app.log.2026-10-01, app.log.3, app.log.gz
are not. That one rule removes rotated logs that no logrotate config covers
— the ones an application rotates itself, into a directory nobody declared —
without ever touching a live file.
docker volume prune is too wide. It is happy to take a database volume
with it. CI runner caches are removed by name instead
(runner-*-cache-*), and one attached to a running job simply fails to
remove, which is the correct outcome.
Below the threshold, do nothing — including logging. A cleanup tool that writes a line every hour to say it had nothing to do is a cleanup tool that fills the disk it is watching.
bash test/run-tests.sh # the script
bash test/run-container-tests.sh # the containers module, against a fake CLI
bash test/run-ansible-tests.sh # the role
ansible-lint ansible/ # profile: production, a gate not adviceThe container suite strips every real docker and podman from PATH and
refuses to run if one is still reachable. That guard exists because writing
it, one case had no shim, the host had a working daemon, and --apply ran
docker image prune -a -f against it for real. A test suite that can issue
destructive commands has to prove it is sandboxed before it issues any.
The tests do not prove it deletes files. That is easy, and most people manage it by accident. They prove the opposite:
- below the threshold, nothing is touched and nothing is logged
- without
--apply, nothing is modified — ever, at any usage level - routine mode removes rotated copies and leaves every active log intact
- emergency mode truncates active files and still never deletes one
- an explicit flag beats the config file
A cleanup tool whose dry run you cannot trust is worse than no cleanup tool.
Run it against a fake tree yourself:
diskwarden --root /tmp/fake-server --usage 95 --modules "logs tmp"--root prefixes every path and --usage overrides the measurement, so you
can reproduce a full disk without having one.
- role: diskwarden
vars:
diskwarden_routine_threshold: 70
diskwarden_extra_log_dirs: [/opt/myapp/logs]
diskwarden_enabled: false # install now, arm after a dry runThe role copies the script rather than templating it, so the file in the repository is the file on the host, byte for byte, and the script stays runnable and testable without Ansible. It ships disarmed: installing diskwarden and arming it are two decisions.
ansible-playbook -i inventory ansible/playbook.example.yml
ssh host 'sudo diskwarden' # read the plan first
ansible-playbook ... -e diskwarden_enabled=true # then arm itansible-lint runs as a gate, at the production profile, pinned in
.ansible-lint. Pinning matters: without it the strictness is whatever the
installed version defaults to, so a green run could mean the bar moved rather
than the code being fine.
bash test/run-ansible-tests.sh checks the thing that silently breaks:
that the role's variable names and the script's config keys still agree.
Rename one side and nothing errors — the config is written, the script sources
it, and the default quietly applies instead of your value.
Three limitations of 0.1.0, written down rather than discovered:
- The dry run cannot put a number on every module.
journalctl --vacuum,apt-get cleananddocker prunereport "would run" with no byte figure, because the only way to know is to run them. The file-based modules do give real numbers. (DW-9) - The containers module is tested against a fake CLI, not a real daemon.
17 cases prove the decision logic — above all that
docker volume pruneis never issued and that onlyrunner-*-cache-*volumes are removed. What they cannot prove is that the commands are still valid, which a future Docker release could change. (DW-10done,DW-18open) dflies on copy-on-write filesystems. On btrfs and ZFS, free space and reclaimable space are different questions and snapshots hold deleted data, so the thresholds would fire late or never. (DW-11)
All three are in BACKLOG.md for v0.2.0.
Each reclaimer is a no-op when the thing it manages is not installed, so one config works across a mixed fleet.
| module | what it reclaims |
|---|---|
logs |
archived and rotated logs under /var/log, plus any EXTRA_LOG_DIRS |
journal |
journalctl --vacuum-time |
packages |
apt / dnf / yum caches |
tmp |
old files in /tmp, then empty directories |
crashdumps |
/var/crash, /var/lib/systemd/coredump |
containers |
Docker or Podman images, containers, build cache; CI runner cache volumes by name |
Adding one is a shell function called reclaim_<name> and a word in
MODULES.
Extracted from an Ansible role that has been running hourly on a production fleet, rewritten as a standalone script with a dry-run default, a config file instead of templating, and the test suite above.
The reclaim logic is the same logic. The packaging, the safety default and
the tests are new, and the fleet-specific parts (a particular CI server's
cache layout, a particular monitoring agent's logrotate) were dropped rather
than generalised — they belong in EXTRA_LOG_DIRS or in your own module.
MIT. Issues and pull requests welcome.