Most Linux servers carry a handful of chores that nobody enjoys: checking disk space, deleting stale logs, taking backups. Python cron automation handles all three with a few small scripts and one scheduler that already ships with every distribution. In this guide we build three complete, tested scripts, wire them into cron with IST schedules, and cover the parts tutorials usually skip: environment pitfalls, locking, logging and secrets. Everything below was run on Python 3.13 in our sandbox; the cron and systemd pieces come from the official docs and man pages.
Why Python cron automation beats a pile of bash scripts
Bash is fine for a one-liner. However, once a job needs thresholds, email, retries or date arithmetic, Python is easier to read and far easier to test. The standard library already includes shutil.disk_usage, pathlib, tarfile, logging, smtplib and fcntl, so you need no third-party packages for the jobs in this post.
Cron, in turn, is the simplest scheduler available. It needs no unit files and every admin understands crontab -e. The modern alternative is a systemd timer, which gives you journal logging, dependency ordering and Persistent=true to catch up on missed runs after a reboot. For example, a daily 02:30 backup as a timer looks like this:
# /etc/systemd/system/backup.timer
[Unit]
Description=Nightly app backup
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.targetThe matching backup.service is a Type=oneshot unit whose ExecStart points at your script. Run systemctl enable --now backup.timer and systemctl list-timers to confirm it. In short: use cron when you want zero ceremony, and timers when you want journald integration or resource limits. The Python scripts are identical either way.
Prepare a venv and a scripts directory
First, create a dedicated user so jobs never run as root. Then give it a home for code, a virtual environment and a log directory.
sudo useradd --system --create-home --home-dir /opt/pyjobs --shell /usr/sbin/nologin pyjobs
sudo -u pyjobs python3 -m venv /opt/pyjobs/venv
sudo -u pyjobs mkdir -p /opt/pyjobs/scripts
sudo install -d -o pyjobs -g pyjobs -m 750 /var/log/pyjobs
sudo chmod 750 /opt/pyjobs /opt/pyjobs/scriptsOn Ubuntu 24.04 the python3-venv package must be installed first. Keep the venv even if you only use the standard library today, because it pins the interpreter path (/opt/pyjobs/venv/bin/python) that you will reference from crontab. Later, if you add a package such as requests, install it with /opt/pyjobs/venv/bin/pip.
Script 1: disk-usage alert with shutil.disk_usage
This script logs the usage of each mount, emails when a threshold is crossed, and exits non-zero so cron (or your monitoring) notices. SMTP settings come from the environment, never from the file itself.
#!/usr/bin/env python3
"""Alert when a mount point passes a usage threshold."""
import logging
import os
import shutil
import smtplib
import sys
from email.message import EmailMessage
from pathlib import Path
MOUNTS = ["/", "/var"]
THRESHOLD = int(os.environ.get("DISK_THRESHOLD", "85"))
LOG_FILE = Path(os.environ.get("LOG_DIR", "/var/log/pyjobs")) / "disk_alert.log"
logging.basicConfig(filename=LOG_FILE, level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("disk_alert")
def send_mail(subject: str, body: str) -> None:
host = os.environ.get("SMTP_HOST")
if not host:
log.warning("SMTP_HOST not set; skipping email")
return
msg = EmailMessage()
msg["From"] = os.environ["ALERT_FROM"]
msg["To"] = os.environ["ALERT_TO"]
msg["Subject"] = subject
msg.set_content(body)
with smtplib.SMTP(host, int(os.environ.get("SMTP_PORT", "587"))) as smtp:
smtp.starttls()
smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASS"])
smtp.send_message(msg)
def main() -> int:
problems = []
for mount in MOUNTS:
try:
usage = shutil.disk_usage(mount)
except FileNotFoundError:
log.error("mount %s not found", mount)
continue
percent = usage.used / usage.total * 100
log.info("%s at %.1f%% used", mount, percent)
if percent >= THRESHOLD:
problems.append(f"{mount}: {percent:.1f}% used "
f"({usage.free // 2**30} GiB free)")
if problems:
body = "\n".join(problems)
log.warning("threshold %d%% exceeded:\n%s", THRESHOLD, body)
send_mail(f"[{os.uname().nodename}] disk usage alert", body)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())Test it without mail by setting DISK_THRESHOLD=1; it should log a warning and exit with status 1.
Script 2: delete logs older than N days with pathlib
Log rotation tools such as logrotate handle system logs, but application directories often fill up unnoticed. This cleanup script walks a directory, skips symlinks, and supports a dry run. The DRY_RUN=1 switch is essential when you point it at a new path for the first time.
#!/usr/bin/env python3
"""Delete files older than N days under a directory."""
import logging
import os
import sys
import time
from pathlib import Path
TARGET = Path(os.environ.get("CLEANUP_DIR", "/var/log/myapp"))
DAYS = int(os.environ.get("CLEANUP_DAYS", "30"))
PATTERN = os.environ.get("CLEANUP_GLOB", "*.log*")
DRY_RUN = os.environ.get("DRY_RUN") == "1"
LOG_FILE = Path(os.environ.get("LOG_DIR", "/var/log/pyjobs")) / "cleanup.log"
logging.basicConfig(filename=LOG_FILE, level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("cleanup")
def main() -> int:
if not TARGET.is_dir():
log.error("%s is not a directory", TARGET)
return 2
cutoff = time.time() - DAYS * 86400
removed = 0
freed = 0
for path in TARGET.rglob(PATTERN):
if not path.is_file() or path.is_symlink():
continue
stat = path.stat()
if stat.st_mtime >= cutoff:
continue
if DRY_RUN:
log.info("would delete %s", path)
else:
path.unlink()
log.info("deleted %s", path)
removed += 1
freed += stat.st_size
log.info("%s %d files, %.1f MiB", "matched" if DRY_RUN else "removed",
removed, freed / 2**20)
return 0
if __name__ == "__main__":
sys.exit(main())One compliance note: CERT-In directions require Indian organisations to retain ICT logs for 180 days. Therefore, never let a cleanup job touch security or access logs with CLEANUP_DAYS below 180.
Script 3: date-stamped tarfile backup with retention and a lock
Backups are the job most likely to overlap, because a slow night can push one run into the next. We take an exclusive lock with fcntl.flock, write to a .part file, then rename atomically so a crash never leaves a half-written archive with a valid name.
#!/usr/bin/env python3
"""Tar a directory with a date stamp and keep only the newest N archives."""
import fcntl
import logging
import os
import sys
import tarfile
from datetime import datetime
from pathlib import Path
SOURCE = Path(os.environ.get("BACKUP_SRC", "/srv/app"))
DEST = Path(os.environ.get("BACKUP_DEST", "/var/backups/app"))
KEEP = int(os.environ.get("BACKUP_KEEP", "7"))
LOCK_FILE = Path(os.environ.get("LOCK_DIR", "/run/lock")) / "backup.lock"
LOG_FILE = Path(os.environ.get("LOG_DIR", "/var/log/pyjobs")) / "backup.log"
logging.basicConfig(filename=LOG_FILE, level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("backup")
def main() -> int:
DEST.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
archive = DEST / f"app-{stamp}.tar.gz"
tmp = archive.with_suffix(".tar.gz.part")
with tarfile.open(tmp, "w:gz") as tar:
tar.add(SOURCE, arcname=SOURCE.name)
os.replace(tmp, archive)
archive.chmod(0o600)
log.info("wrote %s (%.1f MiB)", archive, archive.stat().st_size / 2**20)
archives = sorted(DEST.glob("app-*.tar.gz"))
for old in archives[:-KEEP]:
old.unlink()
log.info("pruned %s", old)
return 0
if __name__ == "__main__":
with open(LOCK_FILE, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
log.warning("previous run still active; exiting")
sys.exit(0)
sys.exit(main())The lock is released automatically when the process exits, even on a crash. On most distributions /run/lock is writable only by root, so give the pyjobs user its own lock directory (for example /run/lock/pyjobs, created via systemd-tmpfiles or at boot) and point LOCK_DIR at it.
Secrets: a .env file instead of passwords in code
Store SMTP credentials in /opt/pyjobs/.env, owned by pyjobs with mode 600. A short wrapper loads the file and runs the chosen script with the venv interpreter.
#!/usr/bin/env bash
# /opt/pyjobs/run_job.sh - load secrets, then run a job
set -euo pipefail
set -a
. /opt/pyjobs/.env
set +a
exec /opt/pyjobs/venv/bin/python "/opt/pyjobs/scripts/$1"If you prefer systemd timers, LoadCredential= in the service unit keeps secrets out of the environment entirely; the script then reads the file at $CREDENTIALS_DIRECTORY. Our free Linux hardening checklist covers file permissions in more depth.
Crontab syntax with IST examples
Edit the dedicated user’s crontab with sudo crontab -u pyjobs -e. Cron reads times in the system timezone, so check timedatectl first. The examples below assume the server is set to Asia/Kolkata.
# min hour dom month dow command
MAILTO=ops@example.in
PATH=/usr/local/bin:/usr/bin:/bin
CRON_TZ=Asia/Kolkata
*/15 * * * * /opt/pyjobs/run_job.sh disk_alert.py
30 1 * * * /opt/pyjobs/run_job.sh cleanup_logs.py
30 2 * * * /opt/pyjobs/run_job.sh backup.py
0 9 * * 1 /opt/pyjobs/run_job.sh weekly_report.pyThat schedules disk checks every 15 minutes, cleanup at 01:30 IST, backups at 02:30 IST and a Monday 09:00 IST report. CRON_TZ is honoured by cronie (RHEL, Fedora); Debian’s Vixie cron ignores it, so set the system timezone there instead. MAILTO sends any output to that address, which is why well-behaved scripts print nothing on success and rely on exit codes plus the log file.
Four environment pitfalls cause most Python cron automation failures, usually reported as “works in my shell, fails in cron” tickets:
- PATH is minimal (
/usr/bin:/bin). Use absolute paths or setPATHat the top of the crontab. - HOME and shell startup files differ;
.bashrcis never sourced. - cwd is the user’s home directory. Build paths with
Path(__file__).parentor absolute paths. - Percent signs are converted to newlines in crontab commands. Escape them as
\%or movedate +%Finto the script.
Testing your Python cron automation before you trust it
python3 -m py_compile /opt/pyjobs/scripts/*.pycatches syntax errors instantly.- Run each job once by hand as the service user:
sudo -u pyjobs env DRY_RUN=1 /opt/pyjobs/run_job.sh cleanup_logs.py. - For scripts placed in
/etc/cron.daily, runrun-parts --test /etc/cron.dailyto list what would execute. Files containing a dot (such asbackup.py) are silently skipped, so drop the extension or use a wrapper. - Watch cron itself:
journalctl -u cron -fon Debian and Ubuntu,journalctl -u crond -fon RHEL, orgrep CRON /var/log/syslogon older hosts. - Check
echo $?after a manual run;0is success, anything else is a failure your monitoring should alert on.
Troubleshooting
Cron runs but the script fails. Nine times out of ten this is PATH or a relative path. Add env > /tmp/cron-env.txt as a temporary job and compare it with your shell.
Output shows up truncated or a command “disappears”. Look for an unescaped % in the crontab line.
Jobs fire at the wrong hour. The server is probably on UTC. Either set timedatectl set-timezone Asia/Kolkata or write schedules in UTC (02:30 IST is 21:00 UTC the previous day).
Permission denied on the log or lock file. Confirm the pyjobs user owns /var/log/pyjobs and the lock directory; never fix this by switching the job to root.
For incident response, remember that CERT-In expects reporting within six hours. A disk-full outage caused by a silent cron failure still counts, so make sure alerts land somewhere humans read. If you want a second pair of eyes on the whole host, our Linux server hardening audit includes cron and scheduled-task review.
Frequently asked questions
Should I use cron or systemd timers in 2026?
Both are fully supported for Python cron automation. Cron is simpler for a single server and portable across distributions. Systemd timers add journal logging, Persistent=true for missed runs, and resource controls such as MemoryMax=. Many teams run cron for user-level jobs and timers for system services.
How do I stop two copies of a Python cron job running at once?
Take an exclusive, non-blocking lock with fcntl.flock on a lock file, as in the backup script above. If the lock is held, exit cleanly. The kernel releases the lock when the process ends, so a crash never leaves a stale lock behind.
Why does my Python cron automation work manually but not from cron?
Cron starts with a tiny environment: a short PATH, no shell aliases and the home directory as the working directory. Use the venv’s absolute interpreter path, absolute file paths, and set PATH and MAILTO at the top of the crontab.
Is it safe to put SMTP passwords in the crontab?
No. The crontab is readable by root and shows up in process listings. Keep secrets in a chmod 600 .env file loaded by a wrapper, or use systemd LoadCredential=. Rotate the credential if it ever appears in a log.
How often should the disk alert run?
Every 15 minutes is a sensible default. Faster checks add little, because the script only exits non-zero and emails when the threshold is crossed; slower checks can miss a runaway log that fills a disk in an hour.
Related guides
- Python Interview Questions and Answers (2026)
- Shell Scripting Interview Questions and Answers (Bash, 2026)
- Linux Fundamentals and Basic Commands for Beginners
- Process Management in Operating System: States, PCB, Scheduling and fork()
Sources
- docs.python.org/3/library/shutil.html#shutil.disk_usage
- docs.python.org/3/library/tarfile.html
- docs.python.org/3/library/fcntl.html
- docs.python.org/3/library/logging.html
- manpages.debian.org/trixie/cron/crontab.5.en.html
- freedesktop.org/software/systemd/man/latest/systemd.timer.html
- freedesktop.org/software/systemd/man/latest/systemd.exec.html#LoadCredential=
- cert-in.org.in/Directions70B.jsp
Running Linux servers in production? Our Linux server hardening & security audit checks configuration, patching, SSH and logging. You can also use our free Linux server hardening checklist, or contact us.
