← all posts

Django periodic tasks without Celery or Redis: 5 Docker Compose setups

You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.

12 min read

You have one Django app, one Postgres and a VPS. You need clearsessions every night and a reminder email every 15 minutes. Every tutorial answers with Celery: a worker, celery beat and a Redis broker. That’s three more containers and a broker to keep alive, for two jobs.

You don’t need any of it for that. We ran five setups that don’t use Celery or Redis in Docker, with Django 6.1.2, gunicorn 26.2, APScheduler 3.11.3, Huey 3.5.3 and django-q2 1.11.1, and noted where each one broke.

3×APScheduler ran each tick under gunicorn -w 3
2×the same scheduler ran under manage.py runserver
0Huey runs with DEBUG = True: the consumer refused to start

Short answer

Write each job as a management command. Then run something that calls it on a schedule, in a second container built from the same image, so the job has the app’s code and environment. For a few jobs and no queue, supercronic with a crontab is the simplest. If you also want to push work out of your views, Huey with SqliteHuey or django-q2 with the ORM broker give you a task queue and periodic tasks with no Redis. Don’t start APScheduler in the web process: every gunicorn worker starts its own copy, and the job runs once per worker.

If you want every run’s output and exit code kept, a timeout, and an alert when a job fails, RunWisp, an open-source cron replacement that ships as a single binary, can run the management commands. Details below.

Containers addedOnce with 3 workersOutput in docker logsPast runs to browseRuns a job missed while downTask queue too
Management command + supercronic1✓✓✗✗✗
APScheduler in the web process0✗✓✗✗✗
Huey with SqliteHuey1✓✓✗✗✓
django-q2 with the ORM broker1✓✓✓ (in admin)✓✓
RunWisp + management commands1✓✓✓✓✗

“Once with 3 workers” means the job runs once per schedule when gunicorn runs three workers. “Past runs to browse” means you can look up last Tuesday’s run and see whether it failed. A scrolled-away line in docker logs doesn’t count.

Start with a management command

Whichever scheduler you pick, put the job in a management command:

# jobs/management/commands/send_reminders.py
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = "Email users whose trial ends tomorrow"

    def handle(self, *args, **options):
        sent = send_trial_reminders()
        self.stdout.write(f"sent {sent} reminders")

You can run it by hand when something goes wrong, test it with call_command(), and change the scheduler later without touching it. Raise CommandError (from django.core.management.base) on failure, so the command exits with code 1 and the scheduler can tell that the run failed.

All the Compose snippets below add one service to a file like this:

# compose.yaml
services:
    web:
        build: .
        command: gunicorn mysite.wsgi -w 3 -b 0.0.0.0:8000
        env_file: .env
        depends_on: [db]
    db:
        image: postgres:17
        env_file: .env
        volumes: [pgdata:/var/lib/postgresql/data]

volumes:
    pgdata:

1. A management command and supercronic

supercronic is a cron for containers. It reads a crontab file, passes the container’s environment to the jobs and prints their output to stdout. Add it to your app’s image:

# Dockerfile, after your app is installed
ADD https://github.com/aptible/supercronic/releases/download/v0.2.51/supercronic-linux-amd64 /usr/local/bin/supercronic
RUN echo "cc232108374e8e2b537045f2d5eaa1229f619db314c3ba6a14d38b35dc0aa123  /usr/local/bin/supercronic" | sha256sum -c - \
 && chmod +x /usr/local/bin/supercronic
COPY crontab /app/crontab
# crontab
0 3 * * *     python manage.py clearsessions
*/15 * * * *  python manage.py send_reminders
# compose.yaml, next to web
scheduler:
    build: .
    command: supercronic /app/crontab
    env_file: .env
    depends_on: [db]

Same image, same .env, a different command. We ran a failing command this way, and docker logs had its output and the exit status:

time="2026-10-25T19:16:00Z" level=info msg="cleanup: DATABASE_URL=postgres://app@db/app cwd=/app" channel=stdout iteration=1 job.command="python manage.py cleanup" ...
time="2026-10-25T19:16:00Z" level=info msg="CommandError: could not reach the database" channel=stderr iteration=1 ...
time="2026-10-25T19:16:00Z" level=error msg="error running command: exit status 1" iteration=1 ...

It also skips a run while the previous one is still going, unless you pass -overlapping.

What it doesn’t do: keep anything after docker logs rotates, re-run a job the container was down for, or stop a command that hangs. It can report failures to Sentry with -sentry-dsn. Otherwise you find out from the logs or from your users.

The host-cron variant. If you’d rather not touch the image, a line in the host’s crontab works:

0 3 * * * cd /srv/app && docker compose exec -T web python manage.py clearsessions

Keep the -T. Without it, docker compose exec asks for a terminal, and under cron it fails with the input device is not a TTY. The output goes to cron’s mail, which on most VPSes means it’s thrown away. Docker Compose cron jobs with a web UI covers that setup in more detail.

2. APScheduler, and the gunicorn trap

The answer you’ll find most often is to start a BackgroundScheduler when Django starts:

# jobs/apps.py. Don't do this.
from django.apps import AppConfig


class JobsConfig(AppConfig):
    name = "jobs"

    def ready(self):
        from apscheduler.schedulers.background import BackgroundScheduler

        scheduler = BackgroundScheduler()
        scheduler.add_job(tick, "interval", seconds=10)
        scheduler.start()

It works with one process. In production that’s rarely the case. Under gunicorn -w 3, each worker is its own process, imports Django and calls ready():

[INFO] Booting worker with pid: 2
[INFO] Booting worker with pid: 3
[INFO] Booting worker with pid: 4
[aps] tick pid=2 t=19:09:20
[aps] tick pid=3 t=19:09:20
[aps] tick pid=4 t=19:09:20

Three workers, three schedulers, every job three times. Your users get three reminder emails. It happens in development too: manage.py runserver ran it twice, because the autoreloader’s parent process loads your apps as well as the child that serves requests.

gunicorn --preload loads the app once in the master process before it forks, and then we saw one tick per interval, from the master’s PID. But the jobs now run inside gunicorn’s master process, and the moment you run two replicas of the web container, or a second app server, you’re back to two.

The fix that django-apscheduler’s README recommends is a dedicated process: a management command that runs a BlockingScheduler.

# jobs/management/commands/runapscheduler.py
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db import close_old_connections


def run(command):
    close_old_connections()  # this process lives for weeks; drop dead DB connections
    call_command(command)


class Command(BaseCommand):
    def handle(self, *args, **options):
        scheduler = BlockingScheduler(timezone="UTC")
        scheduler.add_job(run, CronTrigger.from_crontab("0 3 * * *"), args=["clearsessions"])
        scheduler.add_job(run, CronTrigger.from_crontab("*/15 * * * *"), args=["send_reminders"])
        scheduler.start()
# compose.yaml, next to web
scheduler:
    build: .
    command: python manage.py runapscheduler
    env_file: .env
    depends_on: [db]

That’s the supercronic setup, written in Python. It’s worth it if you want schedules in code, or jobs that aren’t management commands. A job that raises is logged and the scheduler keeps going. With the default in-memory job store, a run missed while the container was down is gone.

APScheduler 4 changes the API completely, and it’s still a pre-release. Pin apscheduler<4.

3. Huey with SQLite

Huey is a small task queue by the author of Peewee. It’s best known with Redis, but it can keep its queue and schedule in a SQLite file:

# settings.py
INSTALLED_APPS += ["huey.contrib.djhuey"]

HUEY = {
    "huey_class": "huey.SqliteHuey",
    "filename": "/data/huey.db",
    "immediate": False,  # see below
}
# jobs/tasks.py
from django.core.management import call_command
from huey import crontab
from huey.contrib.djhuey import db_periodic_task


@db_periodic_task(crontab(hour="3", minute="0"))
def clearsessions():
    call_command("clearsessions")
# compose.yaml, next to web
huey:
    build: .
    command: python manage.py run_huey
    env_file: .env
    volumes: [huey-data:/data]
    depends_on: [db]

# and under volumes:
#   huey-data:

If your views enqueue tasks too (@db_task), mount huey-data into web as well, so both containers use the same file. Keep it on a local volume: SQLite on NFS or a network share is asking for lock errors.

Three things bit us:

  • immediate follows DEBUG. If you don’t set it, djhuey sets immediate = DEBUG. A fresh startproject has DEBUG = True, so tasks run inline in your view and run_huey exits at once:

    huey.exceptions.ConfigurationError: Consumer cannot be run with Huey instances where immediate is enabled. Please check your configuration and ensure that "huey.immediate = False".

    Set "immediate": False if you want to run the consumer locally.

  • crontab() is UTC. Huey defaults to utc=True and compares the crontab against UTC, whatever your TIME_ZONE is. hour="3" on a site set to Europe/Berlin is 04:00 or 05:00 Berlin time, depending on the season. Pass "utc": False in HUEY to use local time.

  • Run one consumer with periodic tasks. Every run_huey process enqueues the periodic tasks. If you scale the consumer to two, start the second with --no-periodic.

A task that raises is logged in the consumer’s output, with the traceback, and the next run goes ahead. Huey checks the schedule once a minute against the current time only, so a run that was due while the consumer was down is skipped. For alerts, connect a handler to Huey’s SIGNAL_ERROR signal and send them yourself.

Huey 3 also has a PostgresHuey storage (needs psycopg) if you’d rather keep the queue in the database you already run.

4. django-q2 with the ORM broker

django-q2, the maintained fork of Django Q, can use your Django database as its broker. Schedules are rows in a table, which you can edit in the admin.

# settings.py
INSTALLED_APPS += ["django_q"]

Q_CLUSTER = {
    "name": "mysite",
    "orm": "default",  # the broker is your DATABASES["default"]
    "workers": 2,
    "timeout": 600,  # kill a task after 10 minutes
    "retry": 720,  # must be longer than timeout
}
pip install django-q2 croniter   # croniter isn't a dependency, but cron schedules need it
python manage.py migrate

Create the schedules in the admin under Django Q › Scheduled tasks, or in code (a data migration or manage.py shell) so they’re versioned:

from django_q.models import Schedule

Schedule.objects.update_or_create(
    name="clearsessions",
    defaults={
        "func": "django.core.management.call_command",
        "args": "'clearsessions'",
        "schedule_type": Schedule.CRON,
        "cron": "0 3 * * *",
    },
)
# compose.yaml, next to web
qcluster:
    build: .
    command: python manage.py qcluster
    env_file: .env
    depends_on: [db]

Every run lands in the admin: successes with the function’s return value, failures with the traceback. That’s the best history of the four. What a task prints doesn’t go there, only into docker logs of the qcluster container. So return the useful bit (return f"sent {n}") instead of printing it.

Things to know:

  • Interval schedules catch up by default. catch_up is True, so a cluster that was down for ten hours runs an HOURLY schedule ten times when it comes back, one at a time, about once a minute, which is how often the scheduler checks. A Schedule.CRON schedule, like the ones above, runs once and moves on to the next future time. Set "catch_up": False on an interval schedule and it runs the job once, then goes back to the normal schedule.
  • The ORM broker polls. Each cluster queries the queue table every 0.2 seconds (poll). On a small Postgres that’s fine, but it’s not nothing; raise poll if you only use schedules.
  • Several clusters don’t duplicate schedules on Postgres or MySQL. The scheduler locks the rows with select_for_update, so running two qcluster containers doesn’t run a job twice.
  • Cron runs in TIME_ZONE, unlike Huey.

5. RunWisp running the management commands

RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single binary. It runs commands on a schedule and keeps every run: output, exit code, duration. It has a web UI, timeouts, and alerts on failure. It doesn’t need a Python package: in Docker you copy the binary into the app’s image and run a second container from it, like supercronic.

# Dockerfile, after your app is installed
COPY --from=runwisp/runwisp:1 /usr/local/bin/runwisp /usr/local/bin/runwisp
COPY runwisp.toml /etc/runwisp/runwisp.toml
# runwisp.toml
[tasks.clearsessions]
cron        = "0 3 * * *"
working_dir = "/app"
run         = "python manage.py clearsessions"
timeout     = "10m"

[tasks.send-reminders]
cron        = "*/15 * * * *"
working_dir = "/app"
run         = "python manage.py send_reminders"
timeout     = "5m"
on_overlap  = "skip"

[notifiers.phone]
type  = "ntfy"
topic = "myapp-jobs-7f3kq9"

[notify]
global_notifiers = ["inapp", "phone"]
# compose.yaml, next to web
runwisp:
    build: .
    command: runwisp daemon
    init: true
    env_file: .env
    environment:
        RUNWISP_CONFIG: /etc/runwisp/runwisp.toml
        RUNWISP_DATA: /var/lib/runwisp
        RUNWISP_HOST: 0.0.0.0
        RUNWISP_PASSWORD: change-me
        RUNWISP_LOG_OUTPUT: "true"
    ports: ["127.0.0.1:9477:9477"]
    volumes: [runwisp-data:/var/lib/runwisp]
    depends_on: [db]

# and under volumes:
#   runwisp-data:

Tasks inherit the container’s environment, so DATABASE_URL and the rest of .env reach manage.py. We ran a command that fails next to one that succeeds. docker logs had both, with each line tagged by task:

[INFO] run started task=cleanup run=01M5T7RNKJ58GPJAWE9NBR6B9E
cleanup | cleanup: DATABASE_URL=postgres://app@db/app cwd=/app
cleanup | CommandError: could not reach the database
[WARN] run failed task=cleanup run=01M5T7RNKJ58GPJAWE9NBR6B9E exit=1 reason=failed dur=190ms
[INFO] run started task=clearsessions run=01M5T7RNX8WQE0XMTMBX4M2CK5
[INFO] run succeeded task=clearsessions run=01M5T7RNX8WQE0XMTMBX4M2CK5 exit=0 dur=177ms

The difference from supercronic is that this is also kept after docker logs rotates. docker compose exec runwisp runwisp logs cleanup prints a past run, and the web UI on port 9477 lists every run with its output. A failed run goes to the ntfy topic (Slack, Discord, Telegram, email and webhooks work too). A run that passes timeout is stopped and recorded as timeout instead of hanging until someone notices. If the container was down at 03:00, clearsessions runs once when it’s back (catch_up = 1, the default). Set catch_up = 0 on a job that shouldn’t.

init: true is there because RunWisp doesn’t reap orphaned processes itself. The schedule is in the image, so a change ships with your next deploy. To run jobs inside the running web container instead, the official image can do that through the Docker socket; see Running in Docker.

The limits:

  • It isn’t a task queue. A view can’t hand it a job. If you need that, use Huey or django-q2.
  • It doesn’t coordinate across hosts. Run one RunWisp container; two would each run every job.
  • It’s another process with its own data volume to keep.
  • It runs on Linux and macOS, and on Windows only under WSL.

What about Django’s own tasks framework?

Django 6.0 added django.tasks: a @task decorator and enqueue(), so libraries can define background work without depending on Celery. It doesn’t run anything on a schedule. Django 6.1 still ships only the ImmediateBackend and the DummyBackend, with no worker and no cron.

A backend from a third party runs the tasks. Huey 3 ships one (huey.contrib.djhuey.tasks_backend.HueyBackend), so the Huey setup above can run django.tasks too. The schedule still comes from Huey’s periodic_task or something outside.

When Celery is the right answer

If you already run Redis, or you need fan-out (one task enqueuing a thousand), per-task retries from code, rate limits, or several worker hosts, Celery is fine and it’s well documented. Run exactly one celery beat, or every beat sends every task. Huey with Redis also covers retries and several worker hosts, with less to configure.

Which one to use

  • Two or three jobs, no queue: a management command and supercronic, in a second container. That’s enough for a lot of apps.
  • You want schedules in Python: APScheduler in its own runapscheduler container, never in the web process.
  • You also need background tasks from views, and no Redis: Huey with SqliteHuey (or PostgresHuey), or django-q2 with the ORM broker if you want history in the admin.
  • Lots of tasks, several worker hosts, fan-out: Celery, or Huey with Redis.
  • You want every run’s output kept, timeouts, and a ping when a job fails: RunWisp. The quick start covers running it on a server without Docker, and the code is on GitHub.

Common questions

How do I run periodic tasks in Django without Celery?
Write each job as a management command, then pick something to call it on a schedule. For a few jobs, a second container from the same image running supercronic with a crontab is the simplest. If you also want a task queue without Redis, Huey with SqliteHuey or django-q2 with its ORM broker both run periodic tasks from a separate consumer container. Don't start APScheduler inside the web process: every gunicorn worker starts its own scheduler, so the job runs once per worker.
Why does my APScheduler job run multiple times with gunicorn?
Each gunicorn worker is a separate process that loads Django and runs AppConfig.ready(), so each one starts its own BackgroundScheduler. With -w 3 the job runs 3 times. runserver does the same thing twice, because the autoreloader's parent process loads the apps too. gunicorn --preload starts one scheduler in the master process, but every replica of the container still runs its own. The fix django-apscheduler recommends is a separate management command that runs a BlockingScheduler, as its own container.
Can Huey run without Redis?
Yes. Set huey_class to huey.SqliteHuey and a filename in settings.HUEY, and the queue and the schedule live in one SQLite file. Huey 3 also has FileHuey and PostgresHuey. Two traps in Django: djhuey turns on immediate mode when DEBUG is True, and run_huey then refuses to start, so set immediate to False. And crontab() is read in UTC by default, whatever TIME_ZONE says.
Does django-q2 need Redis?
No. With Q_CLUSTER = {'orm': 'default'} it uses your Django database as the broker, and schedules are rows you can edit in the admin. Install croniter as well for cron schedules, since django-q2 doesn't pull it in. The ORM broker polls the database every 0.2 seconds by default, and catch_up is on by default, so a cluster that was down runs a cron schedule once when it comes back, while interval schedules such as HOURLY replay every missed run, about one a minute, until they are current.
Does Django 6 have a built-in scheduler?
No. Django 6.0 added django.tasks, a common API for background tasks, and Django 6.1 still ships only the immediate and dummy backends. There is no worker and no schedule or cron support. A third-party backend runs the tasks; Huey 3 ships one, huey.contrib.djhuey.tasks_backend.HueyBackend.
How do I see the output and exit code of every scheduled Django job?
RunWisp, an open-source cron replacement that ships as a single binary, can run each management command on a schedule and keep every run's output, exit code and duration, with a web UI, timeouts, and alerts on failure. In Docker you copy the binary into your app image and run it as a second container, so the jobs get the app's code and environment and nothing is added to requirements.txt. Its limits: it isn't a task queue, so jobs can't be enqueued from your views; it doesn't coordinate across several hosts; and it runs on Linux and macOS, not Windows outside WSL.
  1. Laravel scheduler in Docker: schedule:run with cron, schedule:work or Supervisor?

    Four ways to run Laravel's scheduler in a container, tested on Laravel 12 and 13: which see your env vars, where the output goes, what docker stop does to a running task, and why schedule:run exits 0 when a task fails.

  2. Nitro scheduled tasks not running in production (Nuxt): 8 things to check

    Your Nuxt scheduledTasks work in nuxi dev and do nothing in production. The experimental.tasks flag, serverless presets that drop tasks without a warning, hosts that stop the process, NODE_ENV=test, node-cluster running each task once per worker, and how to keep a record of runs.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh