Scheduling Python Scripts With Systemd On Linux

What You’ll Need
Before starting this guide, ensure you have access to the following hosting tools and infrastructure:
- Hetzner VPS or Contabo VPS running Ubuntu 22.04 / 24.04 or Debian 12
- DigitalOcean droplet as an alternative VPS host
- Namecheap for configuring custom domain DNS if your script sends webhooks to public web endpoints
- Python 3.10 or newer installed on your server
- Root or
sudoaccess on your Linux instance - n8n Cloud or self-hosted n8n if you decide to trigger workflow webhooks alongside system tasks
Table of Contents
- Why Systemd Timers Beat Traditional Cron Jobs
- Step 1: Setting Up a Dedicated Python Project and Virtual Environment
- Step 2: Constructing the Systemd Service Unit
- Step 3: Creating and Configuring the Systemd Timer Unit
- Step 4: Activating, Controlling, and Monitoring Timers
- Step 5: Hardening and Resource Sandboxing for Production
- Getting Started
Why Systemd Timers Beat Traditional Cron Jobs
For decades, cron was the default choice for running periodic background tasks on Linux. If you needed a script to run every hour at minute 15, you opened crontab -e and typed 15 * * * * /usr/bin/python3 /path/to/script.py. While simple, cron introduces major maintenance headaches in modern production environments:
- Opaque Execution & Poor Logging: Standard
cronemails output tomailxor silences standard error unless manually redirected to a custom file. When a job fails silently, you rarely know why. - Environment Path Issues: Cron runs inside a stripped-down shell environment where
PATHvariable mismatches frequently break scripts reliant on Virtual Environments or system binaries. - No Native Dependency Management: Cron cannot natively check if network interfaces are up, if a database container is running, or if disk space is available before firing.
- Lack of Missed Run Catch-up: If your server is offline or restarting when a cron job is set to trigger, that invocation is lost forever.
- No Built-in Resource Limits: A malfunctioning cron script can consume 100% of CPU cores or memory, crashing the entire host server.
Systemd timers solve every single one of these problems natively. Because systemd treats timers as explicit controllers for standard systemd services, your Python tasks gain instant access to journalctl structured logging, dependency management (After=network-online.target), persistent catch-up execution after reboots (Persistent=true), and kernel-level resource sandboxing via cgroups.
If you prefer building workflow automations using drag-and-drop interfaces instead of direct system scripting, you can also explore options like 5 n8n Workflows That Replace $200/Month in SaaS Tools . However, for low-level system maintenance, local database maintenance, and direct hardware API integration, native Linux systemd timers remain the most robust solution available.
Step 1: Setting Up a Dedicated Python Project and Virtual Environment
When spinning up a new cloud instance on Hetzner VPS
or DigitalOcean
, you should avoid running scheduled tasks under the root account or using system-wide Python global packages. We will create an isolated system user, set up a dedicated directory under /opt, create a virtual environment, and install our target script.
Execute the following commands in your terminal to set up the runtime environment:
sudo useradd -m -s /bin/bash apprunner
sudo mkdir -p /opt/py-metrics
sudo chown -R apprunner:apprunner /opt/py-metrics
sudo -u apprunner python3 -m venv /opt/py-metrics/venv
Next, create an isolated environment file to hold configuration parameters and secrets at /opt/py-metrics/.env:
sudo -u apprunner cat << 'EOF' > /opt/py-metrics/.env
LOG_LEVEL=INFO
METRICS_OUTPUT_FILE=/opt/py-metrics/system_health.json
CHECK_ENDPOINT=https://api.ipify.org?format=json
EOF
Now write a full, production-ready Python script located at /opt/py-metrics/metrics_collector.py. This script checks disk space, system memory, queries an external endpoint, and writes the structured result to disk.
import os
import sys
import json
import shutil
import urllib.request
import urllib.error
from datetime import datetime, timezone
def get_env_variable(key: str, default: str = None) -> str:
return os.environ.get(key, default)
def collect_disk_usage(path: str = "/"):
total, used, free = shutil.disk_usage(path)
return {
"total_gb": round(total / (1024 ** 3), 2),
"used_gb": round(used / (1024 ** 3), 2),
"free_gb": round(free / (1024 ** 3), 2),
"percent_used": round((used / total) * 100, 2)
}
def fetch_external_ip(endpoint: str) -> str:
req = urllib.request.Request(
endpoint,
headers={'User-Agent': 'SystemdPythonCollector/1.0'}
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
if response.status == 200:
data = json.loads(response.read().decode('utf-8'))
return data.get('ip', 'unknown')
except urllib.error.URLError as e:
sys.stderr.write(f"Network error while reaching endpoint: {e}\n")
return "unreachable"
except Exception as e:
sys.stderr.write(f"Unexpected error: {e}\n")
return "error"
return "unknown"
def main():
log_level = get_env_variable("LOG_LEVEL", "INFO")
output_path = get_env_variable("METRICS_OUTPUT_FILE", "/tmp/metrics.json")
endpoint = get_env_variable("CHECK_ENDPOINT", "https://api.ipify.org?format=json")
print(f"[{datetime.now(timezone.utc).isoformat()}] [{log_level}] Starting execution...")
disk_info = collect_disk_usage("/")
public_ip = fetch_external_ip(endpoint)
payload = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"public_ip": public_ip,
"disk_metrics": disk_info
}
try:
with open(output_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
print(f"[{datetime.now(timezone.utc).isoformat()}] [{log_level}] Successfully wrote metrics to {output_path}")
except IOError as e:
sys.stderr.write(f"Failed to write metrics output file: {e}\n")
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Set appropriate ownership and permissions for the Python script:
sudo chmod 755 /opt/py-metrics/metrics_collector.py
sudo chown apprunner:apprunner /opt/py-metrics/metrics_collector.py
sudo chmod 600 /opt/py-metrics/.env
Step 2: Constructing the Systemd Service Unit
Systemd split timed tasks into two components:
- The
.servicefile: Defines what executable to run, who runs it, working directories, and environment variables. - The
.timerfile: Defines when and how often to trigger the corresponding service file.
Create the service unit file at /etc/systemd/system/metrics-collector.service:
[Unit]
Description=Python System Metrics Collector Service
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=apprunner
Group=apprunner
WorkingDirectory=/opt/py-metrics
EnvironmentFile=/opt/py-metrics/.env
ExecStart=/opt/py-metrics/venv/bin/python3 /opt/py-metrics/metrics_collector.py
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Let’s break down the critical keys inside this unit configuration:
Type=oneshot: Informs systemd that this process performs a task and exits immediately. Systemd will wait for the Python script to terminate cleanly before treating the execution as complete.ExecStart: Points directly to the Python interpreter inside our virtual environment (/opt/py-metrics/venv/bin/python3). This bypasses path resolution ambiguity entirely and ensures packages installed inside the virtualenv are usable.EnvironmentFile: Automatically parses key-value pairs from/opt/py-metrics/.envand injects them into Python’sos.environ.StandardOutput=journal/StandardError=journal: Routesprint()statements andsys.stderrwrite calls directly to systemd’s centralized logging engine.
💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Step 3: Creating and Configuring the Systemd Timer Unit
Now create the accompanying timer configuration unit. The timer file must share the same base name as the service file (e.g., metrics-collector.timer matches metrics-collector.service).
Create /etc/systemd/system/metrics-collector.timer:
[Unit]
Description=Run Python System Metrics Collector Every 15 Minutes
Requires=metrics-collector.service
[Timer]
Unit=metrics-collector.service
OnCalendar=*:0/15
RandomizedDelaySec=30
Persistent=true
[Install]
WantedBy=timers.target
Understanding Calendar Expressions in Systemd
The OnCalendar directive uses the syntax DayOfWeek Year-Month-Day Hour:Minute:Second. Here are common expressions you can use for scheduling background workers:
OnCalendar=*:0/15: Executes every 15 minutes (at :00, :15, :30, :45).OnCalendar=*-*-* 02:00:00: Runs daily at exactly 2:00 AM UTC.OnCalendar=Mon..Fri *-*-* 09:00:00: Runs every weekday morning at 9:00 AM.OnCalendar=monthly: Runs at midnight on the first day of every month.
The auxiliary directives in our [Timer] block provide advantages unavailable in raw cron setups:
RandomizedDelaySec=30: Staggers execution by up to 30 random seconds to avoid thundering herd problems on external API servers.Persistent=true: If your Contabo VPS was powered off or restarting when a trigger was missed, systemd will execute the missed run immediately upon startup.
If you are developing complex automation jobs that make external requests—such as web scrapers or media bots like the one detailed in How to Build a YouTube Upload Bot with Node.js and OAuth2
—Persistent=true guarantees that scheduled content publishing windows are never dropped without a log trace.
Step 4: Activating, Controlling, and Monitoring Timers
With both unit files in place, instruct systemd to parse the new configs and enable the schedule:
sudo systemctl daemon-reload
sudo systemctl enable --now metrics-collector.timer
Inspecting Timer Schedules
To verify that your timer is active and see next scheduled run times across the server, execute:
systemctl list-timers --all
You will see tabular output similar to this:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Wed 2026-03-25 14:15:00 UTC 11min left Wed 2026-03-25 14:00:02 UTC 3min ago metrics-collector.timer metrics-collector.service
Testing the Service Manually
You do not need to alter system clock settings or wait 15 minutes to confirm that your script executes without errors. Trigger the backing .service directly:
sudo systemctl start metrics-collector.service
Check the status of the execution:
systemctl status metrics-collector.service
Reading Real-time Logs via Journalctl
Systemd aggregates stdout and stderr logs natively. To tail the output generated by your Python script over time, run:
sudo journalctl -u metrics-collector.service -f --output=cat
Sample output:
[2026-03-25T14:00:02.124582+00:00] [INFO] Starting execution...
[2026-03-25T14:00:02.482910+00:00] [INFO] Successfully wrote metrics to /opt/py-metrics/system_health.json
Step 5: Hardening and Resource Sandboxing for Production
If your script processes untrusted user input, handles HTTP hooks, or runs on public instances, you must isolate its system privileges. Systemd provides native security sandboxing features built directly on Linux kernel namespaces and cgroups.
To harden the execution runtime, open /etc/systemd/system/metrics-collector.service and add security constraints under the [Service] block:
[Unit]
Description=Python System Metrics Collector Service (Hardened)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=apprunner
Group=apprunner
WorkingDirectory=/opt/py-metrics
EnvironmentFile=/opt/py-metrics/.env
ExecStart=/opt/py-metrics/venv/bin/python3 /opt/py-metrics/metrics_collector.py
StandardOutput=journal
StandardError=journal
# Sandboxing Security Directives
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/py-metrics
PrivateTmp=true
NoNewPrivileges=true
CapabilityBoundingSet=
# Resource Limits
MemoryMax=256M
CPUQuota=50%
[Install]
WantedBy=multi-user.target
Security Directive Breakdown:
ProtectSystem=strict: Mounts the entire file system (/usr,/boot,/etc, etc.) as read-only for this execution context.ReadWritePaths=/opt/py-metrics: Explicitly punches a write-permission hole throughProtectSystemonly for the directory where the output file lives.PrivateTmp=true: Creates an isolated/tmpnamespace invisible to other users on the host.MemoryMax=256M: If a memory leak causes Python memory usage to exceed 256 Megabytes, systemd automatically halts the process before it invokes the Linux Kernel Out-Of-Memory (OOM) killer on other services.CPUQuota=50%: Limits total process execution to half of a single CPU core.
When protecting self-hosted services, script endpoints, and API collectors from exploitation or runaway processes, applying strict process limits works hand in hand with network controls like Protecting Self Hosted Endpoints with Rate Limiting .
Reload systemd to enforce the sandbox limits:
sudo systemctl daemon-reload
sudo systemctl restart metrics-collector.timer
Getting Started
Deploying standard systemd timers on high-performance infrastructure like Hetzner VPS , Contabo VPS , or a flexible node on DigitalOcean gives you full observability, isolation, and auto-recovery for all background Python jobs. If your workflows require domain routing for webhooks, set up host records using Namecheap to keep all external connections secure.
Outsource Your Automation
Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr — mention SYS3-HUGO for priority. Or DM at chasebot.online .
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
Want this engine running on your own VPS?
This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.
Get System 3