• Post author:Bhaskar Soni
  • Post published:
  • Post category:Linux
  • Post comments:0 Comments
  • Reading time:10 mins read

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.target

The 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/scripts

On 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.py

That 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 set PATH at the top of the crontab.
  • HOME and shell startup files differ; .bashrc is never sourced.
  • cwd is the user’s home directory. Build paths with Path(__file__).parent or absolute paths.
  • Percent signs are converted to newlines in crontab commands. Escape them as \% or move date +%F into the script.

Testing your Python cron automation before you trust it

  1. python3 -m py_compile /opt/pyjobs/scripts/*.py catches syntax errors instantly.
  2. 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.
  3. For scripts placed in /etc/cron.daily, run run-parts --test /etc/cron.daily to list what would execute. Files containing a dot (such as backup.py) are silently skipped, so drop the extension or use a wrapper.
  4. Watch cron itself: journalctl -u cron -f on Debian and Ubuntu, journalctl -u crond -f on RHEL, or grep CRON /var/log/syslog on older hosts.
  5. Check echo $? after a manual run; 0 is 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

Sources

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.

Post author avatar

Bhaskar Soni

Bhaskar Soni is a Security Engineer and the founder of Youngster Company, based in Ahmedabad, Gujarat. He works hands-on with Linux server hardening, vulnerability assessment and penetration testing (VAPT), ISO 27001 compliance and OSINT investigations. He writes practical, tested guides for IT professionals and Indian businesses, and every article on this site is written or reviewed by him.

Leave a Reply