/var/log/journal is 3.8GB and you never put it there.
journald did. It is allowed to take a share of the disk, and on a busy host it takes all of it. This guide covers checking how much it holds, clearing it now, capping it so it stays capped, and the part people skip — keeping the entries before you delete them.
journalctl --disk-usage
Archived and active journals take up 3.8G in the file system.
That is the number journald manages. du -sh /var/log/journal gives the same
answer from the other side, and disagreeing by a few megabytes is normal.
If /var/log/journal does not exist, your journal is in /run/log/journal,
which is a tmpfs. It is using RAM, not disk, and a reboot empties it.
Two commands, in this order:
sudo journalctl --rotate
sudo journalctl --vacuum-time=1s
The order matters. --vacuum-* only deletes archived journal files. The
file journald is currently writing is never a candidate. --rotate closes that
file and archives it, which is what makes the next command able to remove it.
Run the vacuum on its own and you will watch --disk-usage barely move, because
everything recent is still in the active file.
Pick whichever limit fits:
| Command | Keeps |
|---|---|
journalctl --vacuum-size=500M |
The newest 500MB |
journalctl --vacuum-time=30d |
The last 30 days |
journalctl --vacuum-files=5 |
The newest 5 journal files |
They combine, and journald applies all of them.
You need root for any of this. As a normal user journalctl operates on your
own user journal, so the command succeeds and frees almost nothing.
Vacuuming is a one-off. The cap belongs in /etc/systemd/journald.conf:
[Journal]
SystemMaxUse=500M
SystemKeepFree=2G
SystemMaxFiles=10
sudo systemctl restart systemd-journald
journalctl --disk-usage
What each one does:
SystemMaxUse — the ceiling for all journal files together. Default is
10% of the filesystem the journal sits on.SystemKeepFree — space journald refuses to eat into, whatever
SystemMaxUse says. Default is 15% of the filesystem. This is the setting
that saves you when the journal shares a partition with your database.SystemMaxFiles — how many archived files to keep. Vacuuming works a
whole file at a time, so this is the real granularity of “how much do I lose
when it trims”.SystemMaxFileSize — the size of one file. Smaller files mean finer
trimming and more of them.The System* settings apply to /var/log/journal. The matching Runtime*
settings apply to /run/log/journal. Set both if you are not sure which one
your host uses.
Do not trust the percentage arithmetic. Set the value, restart, and read
journalctl --disk-usage back.
[Journal]
Storage=persistent
Storage= decides where the journal lives.
| Value | Journal goes to | Survives a reboot |
|---|---|---|
persistent |
/var/log/journal, created if missing |
Yes |
volatile |
/run/log/journal |
No |
auto (default) |
/var/log/journal if the directory already exists, otherwise /run |
Depends |
none |
Nowhere | No |
auto is the one that surprises people. The directory decides, not the config.
Debian has shipped for years without /var/log/journal, so a stock Debian box
keeps no journal across reboots and nobody notices until the day they need last
week’s logs.
Check yours:
ls -d /var/log/journal 2>/dev/null || echo "volatile — logs die at reboot"
Disk is not the only limit. journald rate-limits each service:
[Journal]
RateLimitIntervalSec=30s
RateLimitBurst=10000
Past the burst inside the interval, journald drops the rest and writes a line saying how many it suppressed. Search for it:
journalctl -g "Suppressed" --since "1 day ago"
A service in a crash loop hits this instantly, so the exact window you want is
the window that got dropped. Raise the burst, or set both to 0 to disable
rate limiting for that host, and remember you have just removed the thing
keeping one chatty service from filling the disk.
There is no undo, no archive, no “are you sure”. --vacuum-time=30d on a host
you are debugging removes the 31-day-old entries that explain the problem.
The uncomfortable version: you are capping the journal because the host produces a lot of logs, and the hosts that produce a lot of logs are the ones worth reading. The cap and the value are in direct conflict as long as both live on the same disk.
They stop conflicting when the logs are somewhere else.
Send the journal to a central server and the local cap is a housekeeping setting instead of a retention policy. Trim to 200MB. The entries still exist.
The one-line version:
journalctl -o json --cursor-file=/var/lib/journal-ship.cursor \
| curl -fsS -X POST --data-binary @- \
"https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"
--cursor-file records where journalctl stopped, so the next run sends only
what arrived since. -o json puts one JSON object per line, and the ingest
endpoint stores one row per line, so the shapes already match.
Use --data-binary, not -d. curl strips newlines out of -d @-, and a batch
of forty entries arrives as one row with all forty jammed together.
Order the two operations correctly. Ship, then vacuum. A cron job that vacuums at 03:00 and ships at 03:05 loses whatever fell inside the window. If you run both on a schedule, put them in one script:
#!/bin/sh
# /usr/local/bin/journal-ship-and-trim
set -eu
CURSOR=/var/lib/journal-ship.cursor
INGEST="https://logs.example.com/api/v1/ingest_logs/YOUR-SOURCE-TOKEN"
OUT=$(journalctl -o json --cursor-file="$CURSOR")
if [ -n "$OUT" ]; then
printf '%s\n' "$OUT" | gzip -c | curl -fsS -X POST --data-binary @- "$INGEST"
fi
journalctl --rotate
journalctl --vacuum-size=200M
Central Logging detects gzip from the first bytes of the body. No
Content-Encoding header is needed, and sending one changes nothing.
The honest caveat. --cursor-file moves the cursor when journalctl runs,
not when curl succeeds. If the POST fails, the script vacuums entries that were
never delivered. The set -eu above does not protect you, because curl is
inside an if. If that matters for your host, drop the vacuum from this script
and run it separately once you trust the upload.
The CL Agent does the shipping half every 30 seconds and
keeps its cursor in /tmp/clagent-cursor.txt. It does not vacuum anything —
that stays your decision.
Once the logs have landed, the question “which host is producing all this” becomes a query rather than a tour of ten machines:
SELECT json_extract(msg, '$._HOSTNAME') AS host,
COUNT(*) AS lines,
SUM(LENGTH(msg)) AS bytes
FROM logs
WHERE json_valid(msg)
AND timestamp > CAST(strftime('%s', 'now', '-1 day') AS INTEGER)
GROUP BY host
ORDER BY bytes DESC;
Then narrow to the unit responsible:
SELECT json_extract(msg, '$._SYSTEMD_UNIT') AS unit,
COUNT(*) AS lines
FROM logs
WHERE json_valid(msg)
AND json_extract(msg, '$._HOSTNAME') = 'web1'
AND timestamp > CAST(strftime('%s', 'now', '-1 day') AS INTEGER)
GROUP BY unit
ORDER BY lines DESC
LIMIT 20;
Keep the json_valid(msg) guard in both. json_extract raises an error the
first time it meets a line that is not JSON, and one plain-text line in the
source is enough to stop the query.
The timestamp column is when the server received the line, not when the event
happened. For a volume question that is the right clock — you want to know what
arrived. For event order, use journald’s own timestamp instead. See
querying logs with SQL.
--cursor-file does on
its first run.💌 Get notified on new features and updates