Mail Log Archival
Context
This note defines how mail logs get exported from journald into durable plain-text archives.
The underlying rule is simple: journald is the authoritative live source, but it is not meant to be the only long-term store. The archive layer exists so history remains readable, compressible, and independent of Loki retention.
Goal
- keep journald as the authoritative, live log source
- persist all mail logs to disk
- avoid duplicates and gaps
- make logs readable
- be robust across reboots and reruns
- prepare the ground for future forensics, analysis, and AI tooling
Environment
- Postfix and Dovecot logging only to journald
- archive output under
/var/log/journal-archive/ - cursor state under
/var/lib/journal-cursors/ - systemd service and timer driving the exporter
The Final State
Both Postfix and Dovecot log only to journald.
I verified this by:
- running
dovecot -nwith no log paths configured - using:
journalctl -u dovecot -f
journalctl -u [email protected] -f
These commands show everything that matters, which confirms journald is the single source of truth.
Bounded hot buffer
I do not want journald to be long-term storage. I want it to be a hot cache that I drain continuously.
/etc/systemd/journald.conf
[Journal]
Storage=persistent
SystemMaxUse=3G
SystemKeepFree=10G
RuntimeMaxUse=256M
MaxRetentionSec=21day
SyncIntervalSec=5m
Then:
systemctl restart systemd-journald
journalctl --disk-usage
This guarantees:
- logs survive reboots
- journald never eats the disk
- there is roughly 2 to 3 weeks of safety if archiving fails
Archive layout
I store derived logs rather than raw journal files here:
/var/log/journal-archive/
├── postfix/
│ └── postfix-YYYY-MM-DD.log
└── dovecot/
└── dovecot-YYYY-MM-DD.log
Cursor state lives separately:
/var/lib/journal-cursors/
├── postfix.cursor
└── dovecot.cursor
Create directories:
mkdir -p /var/log/journal-archive/{postfix,dovecot}
mkdir -p /var/lib/journal-cursors
Cursor-based exporter
I do not use --since yesterday because:
- it causes duplicates if rerun
- it has boundary bugs
Instead, I use journald cursors, which are opaque bookmarks.
/usr/local/bin/archive-mail-journal-cursor.sh
#!/bin/bash
set -euo pipefail
BASE="/var/log/journal-archive"
STATE="/var/lib/journal-cursors"
mkdir -p "$STATE" "$BASE/postfix" "$BASE/dovecot"
append_unit() {
local unit="$1"
local name="$2"
local cursor_file="$STATE/$name.cursor"
local out_file="$BASE/$name/$name-$(date +%F).log"
local cursor=""
if [[ -s "$cursor_file" ]]; then
cursor="$(head -n 1 "$cursor_file" | tr -d '\r\n')"
fi
local before_size after_size
before_size=$(stat -c%s "$out_file" 2>/dev/null || echo 0)
if [[ "$cursor" == s=* && "$cursor" == *";i="* ]]; then
journalctl -u "$unit" --after-cursor="$cursor" \
--no-pager --output=short-iso-precise >> "$out_file"
else
journalctl -u "$unit" --since "15 minutes ago" \
--no-pager --output=short-iso-precise >> "$out_file"
fi
after_size=$(stat -c%s "$out_file" 2>/dev/null || echo 0)
if (( after_size > before_size )); then
local newest
newest="$(journalctl -u "$unit" -n 1 --show-cursor --no-pager \
| sed -n 's/^-- cursor: //p' | tail -n 1 | tr -d '\r\n')"
if [[ "$newest" == s=* && "$newest" == *";i="* ]]; then
echo "$newest" > "$cursor_file"
fi
fi
}
append_unit "[email protected]" "postfix"
append_unit "dovecot" "dovecot"
Make it executable:
chmod +x /usr/local/bin/archive-mail-journal-cursor.sh
This guarantees:
- no duplicates
- no gaps
- safe reruns
- cursor advances only when new logs were written
Run every 5 minutes
Service unit:
/etc/systemd/system/mail-journal-archive.service
[Unit]
Description=Archive Postfix and Dovecot journald logs (cursor-based)
After=systemd-journald.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/archive-mail-journal-cursor.sh
User=root
Timer unit:
/etc/systemd/system/mail-journal-archive.timer
[Unit]
Description=Run mail journal archiver every 5 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
Persistent=true
AccuracySec=30s
[Install]
WantedBy=timers.target
Enable it:
systemctl daemon-reload
systemctl enable --now mail-journal-archive.timer
Verification
Verify the timer and recent service activity:
systemctl list-timers | grep mail-journal
journalctl -u mail-journal-archive.service --since "30 minutes ago"
View current logs:
tail -f /var/log/journal-archive/postfix/postfix-$(date +%F).log
less /var/log/journal-archive/dovecot/dovecot-$(date +%F).log
View older compressed logs:
zless /var/log/journal-archive/postfix/postfix-2026-01-30.log.gz
zgrep "auth failed" /var/log/journal-archive/dovecot/*.gz
This now fully replaces journalctl -f for most archive-review workflows.
Compression
Daily compression job:
find /var/log/journal-archive -type f -name "*.log" -mtime +2 -exec gzip -f {} \;
This gives years of retention with minimal disk usage.
Failure Modes Worth Caring About
- time-sliced export reintroducing duplicates or gaps
- cursor files getting corrupted or advanced incorrectly
- archive timer stopping silently
- archive files growing stale while journald still looks healthy
- treating cursor files as logs instead of state