Schedule Django Management Commands with Cron or systemd Timers
Run Django management commands on a schedule with systemd timers or cron: environment loading, overlap locks, logging, and failure alerts.
Problem statement
Most Django projects need a few recurring jobs: clearsessions, sending reminders, syncing data from an API, generating sitemaps. Not every project needs Celery for that. The operating system's scheduler can run management commands directly, with no broker and no long-running scheduler process.
The problems come from the gap between "the command works in my SSH session" and "the command works at 03:00 with nobody watching":
- cron runs with a minimal environment, so settings, secrets, and the virtualenv are missing
- a job that takes longer than its interval starts again while the first run is still going
- output is emailed to a local mailbox nobody reads, or discarded
- the job fails for weeks before anyone notices
- times are ambiguous because the server's time zone differs from the application's
This guide schedules management commands with systemd timers (recommended on modern Linux) and shows the equivalent cron setup, with environment loading, overlap protection, logging, and failure alerts.
Quick answer
- Create a oneshot systemd service that runs
manage.py <command>as the app user, withEnvironmentFileandWorkingDirectoryset. - Create a matching
.timerwithOnCalendar=andPersistent=true. - Enable the timer with
systemctl enable --now name.timer. - systemd will not start a service instance that is already running, which prevents overlap; with cron, wrap commands in
flock -n. - Read logs with
journalctl -u name.service; addOnFailure=to trigger an alert. - Test by starting the service manually and checking
systemctl list-timers.
Step-by-step solution
1. Write a robust management command
# core/management/commands/send_reminders.py
import logging
from django.core.management.base import BaseCommand, CommandError
from core.reminders import send_due_reminders
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Send reminder emails that are due."
def add_arguments(self, parser):
parser.add_argument("--limit", type=int, default=500)
def handle(self, *args, **options):
try:
sent = send_due_reminders(limit=options["limit"])
except Exception as exc:
logger.exception("send_reminders failed")
raise CommandError(str(exc)) from exc
self.stdout.write(f"sent={sent}")
Raising CommandError makes manage.py exit with a non-zero status, which is what schedulers use to detect failure. A --limit bounds the work per run so one run cannot grow unbounded after a backlog.
2. Create the service unit
/etc/systemd/system/myapp-send-reminders.service:
[Unit]
Description=myapp: send due reminders
After=network-online.target postgresql.service
Wants=network-online.target
OnFailure=myapp-job-failed@%n.service
[Service]
Type=oneshot
User=myapp
Group=myapp
WorkingDirectory=/srv/myapp/app
EnvironmentFile=/etc/myapp/env
ExecStart=/srv/myapp/venv/bin/python manage.py send_reminders --limit 500
TimeoutStartSec=15min
Nice=10
Type=oneshotfits commands that run and exit.EnvironmentFilegives the job exactly the same configuration as the web service, so it connects to the same database with the same settings.TimeoutStartSeckills a hung run instead of letting it block future runs forever.Nice=10lowers CPU priority relative to web workers.OnFailure=starts another unit when this one fails (step 5).
3. Create the timer
/etc/systemd/system/myapp-send-reminders.timer:
[Unit]
Description=Run myapp send_reminders every 15 minutes
[Timer]
OnCalendar=*:0/15
RandomizedDelaySec=60
Persistent=true
[Install]
WantedBy=timers.target
OnCalendar=*:0/15runs at minutes 0, 15, 30, and 45 of every hour. A daily job at 03:30 would beOnCalendar=*-*-* 03:30:00.RandomizedDelaySecspreads jobs across a window so many timers on one server do not all start at exactly the same second.Persistent=trueruns a missed job once at boot if the server was down when it should have run.
Validate calendar expressions before enabling:
systemd-analyze calendar "*:0/15"
systemd-analyze calendar "*-*-* 03:30:00" --iterations=3
OnCalendar times are interpreted in the server's local time zone unless you append a zone, for example *-*-* 03:30:00 UTC. Being explicit avoids daylight saving surprises.
Enable:
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-send-reminders.timer
systemctl list-timers 'myapp-*'
4. Overlap protection
If a run is still active when the timer fires again, systemd does not start a second instance of the same service; the activation is simply skipped. That built-in behavior prevents the most common cron problem, two copies of a job processing the same rows.
Still design commands to be safe if run twice (for example by an operator running it manually during a scheduled run): mark rows as processed in a transaction, or use select_for_update(skip_locked=True) when selecting work on PostgreSQL.
5. Failure alerts
Create a template unit that sends an alert when any job fails:
/etc/systemd/system/myapp-job-failed@.service:
[Unit]
Description=Alert for failed job %i
[Service]
Type=oneshot
EnvironmentFile=/etc/myapp/alert.env
ExecStart=/usr/bin/curl -fsS -m 10 -X POST \
--data-urlencode "text=Job %i failed on %H" \
${ALERT_WEBHOOK_URL}
%i is the failing unit's name and %H is the hostname. Point ALERT_WEBHOOK_URL at a chat webhook or incident tool. For "job did not run at all" detection, add a success ping at the end of the job to a dead man's switch service and alert when pings stop.
6. Logs
Output from ExecStart goes to the journal:
journalctl -u myapp-send-reminders.service --since today
journalctl -u myapp-send-reminders.service -n 50 --no-pager
systemctl status myapp-send-reminders.service
systemctl status shows the last run's exit status. Make sure journal retention is long enough to investigate issues (see SystemMaxUse in journald.conf).
7. The cron equivalent
If you use cron, create a script that sets up the environment explicitly:
/srv/myapp/bin/run-job:
#!/bin/bash
set -euo pipefail
set -a
. /etc/myapp/env
set +a
cd /srv/myapp/app
exec /srv/myapp/venv/bin/python manage.py "$@"
sudo chmod 750 /srv/myapp/bin/run-job
sudo chown root:myapp /srv/myapp/bin/run-job
Then install a cron file, /etc/cron.d/myapp:
SHELL=/bin/bash
PATH=/usr/local/bin:/usr/bin:/bin
CRON_TZ=UTC
*/15 * * * * myapp flock -n /tmp/myapp-send-reminders.lock /srv/myapp/bin/run-job send_reminders --limit 500 >> /var/log/myapp/send_reminders.log 2>&1
30 3 * * * myapp flock -n /tmp/myapp-clearsessions.lock /srv/myapp/bin/run-job clearsessions >> /var/log/myapp/clearsessions.log 2>&1
- Files in
/etc/cron.d/include a user column (myapp). flock -nexits immediately if the previous run still holds the lock, preventing overlap.CRON_TZis supported by cronie and some other cron implementations but not by every cron; check your distribution'scrontab(5)manual page, or keep the server itself on UTC.- Rotate
/var/log/myapp/*.logwith logrotate.
Cron has no built-in failure alerting beyond MAILTO, so add a check in the script or ping a monitoring service on success.
8. Containers
In Docker-based deployments, running cron inside the web container is fragile. Options:
- a host-level systemd timer that runs
docker compose run --rm web python manage.py send_reminders - a Kubernetes
CronJobwithconcurrencyPolicy: Forbid - your platform's scheduled job feature (for example a scheduled task on a PaaS)
9. Test
# Run the job immediately exactly as the timer would
sudo systemctl start myapp-send-reminders.service
systemctl status myapp-send-reminders.service --no-pager
journalctl -u myapp-send-reminders.service -n 20 --no-pager
# Confirm the next scheduled run
systemctl list-timers myapp-send-reminders.timer
To test failure alerting, temporarily add an invalid argument to ExecStart, start the service, confirm the alert arrives, and then revert.
Verification check:
- a manual start completes with
status=0/SUCCESS list-timersshows the expected next run time- the journal shows the command's output
- a forced failure triggers the alert unit
Explanation
A scheduled management command needs the same three things as the web process (code, virtualenv, configuration), plus scheduling, concurrency control, and observability. systemd timers provide all of those in the service manager you already use for Gunicorn: the service unit reuses the web configuration, timer units handle scheduling and missed runs, singleton activation prevents overlap, and the journal plus OnFailure= give you logs and alerts.
Edge cases / notes
Several servers
If you run the app on multiple servers, enable each timer on one server only, or use a database-level lock in the command, otherwise every server runs the job.
Long-running jobs
Jobs that take hours are better modeled as background workers processing a queue in batches, so deploys and restarts do not kill half-finished work.
When to switch to Celery Beat
Move to Celery Beat when you already run Celery, need many schedules managed from the admin, or need tasks to fan out into many smaller tasks.
Troubleshooting scheduled jobs
| Symptom | Cause | Fix |
|---|---|---|
Timer shows n/a for next run | timer not enabled or started | systemctl enable --now name.timer |
| Job ran at unexpected time | calendar expression or time zone misunderstanding | systemd-analyze calendar, append UTC |
status=203/EXEC | wrong path in ExecStart | use absolute paths to the virtualenv Python |
status=1/FAILURE with KeyError: 'DJANGO_SECRET_KEY' | EnvironmentFile missing or unreadable | check path and permissions |
| Job killed after a while | TimeoutStartSec reached | optimize or batch the job; raise the timeout deliberately |
| Cron job never runs | missing user column in /etc/cron.d/, file not executable path, invalid filename | check /var/log/syslog or journalctl -u cron |
Files in /etc/cron.d/ are skipped if their names contain dots or other characters outside letters, digits, underscores, and hyphens on Debian-based systems. Name the file myapp, not myapp.cron.
Making jobs observable
Record success in a place your monitoring can read, not only in logs:
# core/models.py
from django.db import models
class JobRun(models.Model):
name = models.CharField(max_length=100, unique=True)
last_success_at = models.DateTimeField()
from django.utils import timezone
from core.models import JobRun
def record_success(name):
JobRun.objects.update_or_create(name=name, defaults={"last_success_at": timezone.now()})
Call record_success("send_reminders") at the end of the command. A health endpoint or monitoring query can then alert when last_success_at is older than the expected interval plus a margin, which catches both failures and jobs that silently stopped running.
Resource isolation
A heavy nightly job can slow the web application on the same server. Beyond Nice=, systemd offers:
[Service]
CPUQuota=50%
MemoryMax=1G
IOSchedulingClass=idle
CPUQuota limits the job to half a CPU, MemoryMax caps memory, and IOSchedulingClass=idle gives disk access lower priority than other processes.
Running a job manually during an incident
Start the service rather than running the command by hand, so the environment, user, and logging are identical:
sudo systemctl start myapp-send-reminders.service
journalctl -u myapp-send-reminders.service -f
If you need different arguments, use systemd-run with the same properties:
sudo systemd-run --unit=myapp-reminders-manual --uid=myapp --gid=myapp \
--property=EnvironmentFile=/etc/myapp/env \
--working-directory=/srv/myapp/app \
/srv/myapp/venv/bin/python manage.py send_reminders --limit 50
Next steps
- Compare with a queue-based scheduler in Run Celery Beat for Scheduled Tasks in Django Production.
- Reuse unit file patterns from How to Configure systemd for Gunicorn and Django.
- Alert on missed jobs with Set Up Uptime Monitoring and Alerts for Django.
FAQ
Why does my Django cron job fail when the command works in my shell?
Cron runs with a minimal environment: no virtualenv, no exported settings, and a short PATH. Load the environment file explicitly and call the virtualenv's Python with absolute paths.
Are systemd timers better than cron for Django jobs?
On systemd-based servers they usually are: jobs log to the journal, reuse the service's environment file, skip starting while a previous run is still active, and can catch up after downtime with Persistent=true.
How do I stop a scheduled Django command from running twice at the same time?
With systemd timers, a service that is still running is not started again. With cron, wrap the command in flock -n with a lock file per job.
How do I run Django management commands on a schedule in Docker?
Use a host-level timer or cron that runs a one-off container, a Kubernetes CronJob with concurrencyPolicy: Forbid, or your platform's scheduled job feature instead of running cron inside the web container.