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.

Scheduled management commandssystemd timerOnCalendaroneshot serviceenv file, app usermanage.py commandbounded workJournal + alertsOnFailureA running instance is never started twice, and failures trigger an alert unit.

Quick answer

  1. Create a oneshot systemd service that runs manage.py <command> as the app user, with EnvironmentFile and WorkingDirectory set.
  2. Create a matching .timer with OnCalendar= and Persistent=true.
  3. Enable the timer with systemctl enable --now name.timer.
  4. systemd will not start a service instance that is already running, which prevents overlap; with cron, wrap commands in flock -n.
  5. Read logs with journalctl -u name.service; add OnFailure= to trigger an alert.
  6. 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=oneshot fits commands that run and exit.
  • EnvironmentFile gives the job exactly the same configuration as the web service, so it connects to the same database with the same settings.
  • TimeoutStartSec kills a hung run instead of letting it block future runs forever.
  • Nice=10 lowers 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
the timer unit[Timer]OnCalendar=*:0/15RandomizedDelaySec=60Persistent=true1231Every 15 minutes; check with systemd-analyze calendar.2Spread start times so servers do not align.3Run a missed schedule after downtime.The timer triggers a .service of the same namedjango-deployment.com
systemctl list-timers shows the next and last run.
  • OnCalendar=*:0/15 runs at minutes 0, 15, 30, and 45 of every hour. A daily job at 03:30 would be OnCalendar=*-*-* 03:30:00.
  • RandomizedDelaySec spreads jobs across a window so many timers on one server do not all start at exactly the same second.
  • Persistent=true runs 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.

no overlapping runscronstarts a new run on schedule✕even if the last is still runningneeds flock to be safesystemd timerservice still active?✓no second instance starts✓built in, no lock fileSeveral servers still need a lock in the database or Redisdjango-deployment.com
Overlap protection on one host does nothing across hosts.

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}
failures that tell someonemyapp-reminders.service failsnon-zero exitOnFailure=notify@%n.servicetriggered automaticallynotify@.servicesends mail, chat, or a webhookHeartbeat monitoralso catches jobs that never ranOnFailure catches failures; heartbeats catch silencedjango-deployment.com
Make the command exit non-zero on real failures.

%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 "$@"
cron and timers side by sidecronsystemd timerEnvironment!minimal: load it yourself✓EnvironmentFile=Logsmail or redirect✓journalctl -uMissed runsskipped✓Persistent=trueOverlap!possible✓preventedcron is fine for simple jobs; timers remove its sharp edgesdjango-deployment.com
Containers usually use the orchestrator’s scheduler instead.
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 -n exits immediately if the previous run still holds the lock, preventing overlap.
  • CRON_TZ is supported by cronie and some other cron implementations but not by every cron; check your distribution's crontab(5) manual page, or keep the server itself on UTC.
  • Rotate /var/log/myapp/*.log with 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 CronJob with concurrencyPolicy: 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-timers shows 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

SymptomCauseFix
Timer shows n/a for next runtimer not enabled or startedsystemctl enable --now name.timer
Job ran at unexpected timecalendar expression or time zone misunderstandingsystemd-analyze calendar, append UTC
status=203/EXECwrong path in ExecStartuse absolute paths to the virtualenv Python
status=1/FAILURE with KeyError: 'DJANGO_SECRET_KEY'EnvironmentFile missing or unreadablecheck path and permissions
Job killed after a whileTimeoutStartSec reachedoptimize or batch the job; raise the timeout deliberately
Cron job never runsmissing user column in /etc/cron.d/, file not executable path, invalid filenamecheck /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

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.

Keep going