Schedule an Excel Report with systemd Timers
cron has run report jobs for decades, and it still works. On a modern Linux server, systemd timers do the same job with fewer surprises: the job's output lands in the journal with timestamps, its exit status is recorded, missed runs after downtime can be caught up, and schedules such as "the first Monday of the month" are expressed directly. This guide, part of Scheduling Python Excel Scripts with cron, moves a weekday Excel report onto a service and timer. The unit files were checked with systemd-analyze verify, each schedule with systemd-analyze calendar, and the logging behaviour with test runs.
Prerequisites
A Linux machine with systemd, the report in a project folder with its own virtual environment, and the report script exiting non-zero on failure. That last point matters more than any unit setting: systemd judges success by the exit status. A minimal entry point that follows the rule:
import logging
import sys
log = logging.getLogger("sales-report")
def build_report():
log.info("building weekly sales report")
# … read inputs, write reports/sales.xlsx …
def main():
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
try:
build_report()
except Exception:
log.exception("report failed")
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Log to standard error without timestamps: the journal adds its own.
Step 1 — Write the service
The service describes how to run the job. Type=oneshot means "run to completion"; the working directory and environment are explicit, so the job behaves the same from a timer as from your shell:
# /etc/systemd/system/sales-report.service
[Unit]
Description=Weekly sales Excel report
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=reports
WorkingDirectory=/srv/sales-report
EnvironmentFile=/srv/sales-report/report.env
Environment=PYTHONUNBUFFERED=1
ExecStart=/srv/sales-report/.venv/bin/python -m sales_report
TimeoutStartSec=30min
Point ExecStart at the virtual environment's own python — no activation step is needed. EnvironmentFile holds settings and secrets as NAME=value lines; make it readable only by the service user. TimeoutStartSec stops a hung run, such as a network share that never answers, instead of letting it block the next day's run.
PYTHONUNBUFFERED=1 fixes a confusing log: when standard output is not a terminal, Python buffers it until exit. In a test run, a warning printed to standard error appeared in the journal before the "building report" line the script had printed first.
Step 2 — Write the timer
The timer describes when. It has the same name as the service, so it starts it automatically:
# /etc/systemd/system/sales-report.timer
[Unit]
Description=Run the sales report on weekday mornings
[Timer]
OnCalendar=Mon..Fri 07:30
Persistent=true
RandomizedDelaySec=5min
[Install]
WantedBy=timers.target
Persistent=true makes systemd remember the last run: if the server was down at 07:30, the report runs once shortly after boot instead of being skipped until tomorrow. RandomizedDelaySec spreads jobs that share a schedule, so twenty reports scheduled at 07:30 do not all hit the database in the same second.
Step 3 — Check the schedule before you trust it
Calendar expressions are easy to get subtly wrong. systemd-analyze calendar prints the next run times, so check every expression before deploying it:
systemd-analyze calendar --iterations=3 "Mon..Fri 07:30"
# Normalized form: Mon..Fri *-*-* 07:30:00
# Next elapse: Thu 2026-09-24 07:30:00 CEST
# Iteration #2: Fri 2026-09-25 07:30:00 CEST
# Iteration #3: Mon 2026-09-28 07:30:00 CEST
Reporting calendars often need schedules that cron cannot express. In cron, a line with both a day of the month and a weekday runs when either matches, so 0 8 1-7 * 1 runs on every one of the first seven days and on every Monday. systemd combines them with and:
The time zone suffix matters when the server runs in UTC but readers expect the report at 07:30 local time, including across daylight saving changes.
Step 4 — Enable, test and inspect
Load the units, run the service once by hand to prove it works, then enable the timer:
sudo systemctl daemon-reload
sudo systemctl start sales-report.service # one run now, waits for completion
sudo systemctl enable --now sales-report.timer
systemctl list-timers sales-report.timer # next and last run
journalctl -u sales-report.service --since today
systemctl status sales-report.service # last result and exit code
A failed run shows in systemctl status as Failed with result 'exit-code' together with the status number, and systemctl --failed lists every failed unit on the machine — a single place to look after a long weekend.
On a machine where you have no root access, the same files go in ~/.config/systemd/user/ and every command takes --user. User timers only run while you are logged in unless an administrator enables lingering for your account with loginctl enable-linger <user>.
Step 5 — Alert on failure
OnFailure= starts another unit whenever the service fails. A small templated alert unit receives the failed unit's name:
# in sales-report.service, [Unit] section
OnFailure=report-alert@%n.service
# /etc/systemd/system/report-alert@.service
[Unit]
Description=Alert about failed %i
[Service]
Type=oneshot
ExecStart=/srv/sales-report/.venv/bin/python -m report_alert %i
report_alert can read the last lines with journalctl -u <unit> -n 50 --no-pager and send them by email or chat, as in Send an Alert When an Excel Report Job Fails. Because the hook lives in systemd, it also fires when the job is killed for running out of memory or hits its timeout — cases where the script's own exception handler never runs.
Step 6 — Put limits around the job
A report that reads a much larger export than usual can exhaust the server's memory and take other services down with it. A service can carry its own limits, and a few sandboxing settings keep a buggy or compromised script from writing anywhere but its output folder:
# in sales-report.service, [Service] section
MemoryMax=2G
CPUQuota=100%
ProtectSystem=strict
ReadWritePaths=/srv/sales-report/reports /srv/sales-report/state
PrivateTmp=yes
NoNewPrivileges=yes
MemoryMax is a hard ceiling for the whole unit. In a test run with a 150 MB limit, a script allocating 400 MB was stopped at once, and the journal recorded The kernel OOM killer killed some processes in this unit followed by Failed with result 'oom-kill' — a clear, specific failure that fires OnFailure= like any other, instead of a server grinding into swap. Size the limit from a normal run's peak, which systemctl status and the journal report as "Memory peak" after each run, and leave generous headroom for month-end files. Reduce Memory When Reading Excel with pandas helps when the peak is too high to begin with.
ProtectSystem=strict makes the whole file system read-only for the job except the paths listed in ReadWritePaths, so the report can write its output and state and nothing else. PrivateTmp=yes gives it a private /tmp, which also means leftover temporary files disappear with the run. systemd-analyze security sales-report.service scores the unit and lists the settings that would tighten it further.
Common pitfalls
- Relative paths. Without
WorkingDirectory, a service starts in/, andopen("config.toml")fails. Set it, or build paths from the script's location. - Editing units without reloading. Changes take effect only after
systemctl daemon-reload. - Enabling the service instead of the timer.
enable sales-report.servicedoes nothing useful for a oneshot job without an[Install]section. Enable the.timer. - Excel-dependent scripts. A Linux timer cannot drive desktop Excel. Reports that need Excel belong on Windows, covered in Run a Python Excel Script on Windows Task Scheduler.
Conclusion
Put the job in a Type=oneshot service with an explicit working directory, environment file, PYTHONUNBUFFERED=1 and a start timeout, and the schedule in a matching timer with Persistent=true. Check every OnCalendar expression with systemd-analyze calendar, test the service by starting it once, read results with journalctl and systemctl status, and add an OnFailure= unit so failures reach a person.
Frequently asked questions
How do I schedule a Python script with systemd? Create a .service unit with Type=oneshot whose ExecStart runs the virtual environment's python on your script, and a .timer unit with the same name and an OnCalendar schedule. Enable and start the timer with systemctl enable --now name.timer.
How do I check an OnCalendar expression before using it? Run systemd-analyze calendar --iterations=5 "Mon..Fri 07:30". It prints the normalised expression and the next run times, so a schedule mistake shows up before the first missed report.
Does a systemd timer run a job that was missed while the server was off? Only with Persistent=true in the Timer section. systemd then records the last run and, after boot, starts the service once if a scheduled time passed while the machine was down.
Where does the output of a systemd timer job go? To the journal. Read it with journalctl -u name.service (add --user for user units). Set PYTHONUNBUFFERED=1 in the service, or Python's standard output is written at exit, after messages on standard error.
Related
- Up one level: Scheduling Python Excel Scripts with cron — running reports on a schedule.
- Run a Python Excel Report in Docker — packaging the job so any scheduler can run it.
- Schedule Recurring Excel Reports with APScheduler — scheduling from inside a long-running Python process.
- Log Python Excel Script Output to a File — when the journal is not an option.
More on scheduling & orchestration
- How-toDownload New Excel Files from an SFTP Server
- How-toRead Excel Attachments into a Database Nightly
- How-toRefresh an Excel Report from a Database on a Schedule
- How-toRun a Report When an Excel File Changes in OneDrive
- How-toSend Report Emails at Each Recipient's Local Time
- How-toDetect a Scheduled Report That Did Not Run