Configure Redis and Celery for Django Production
Running background jobs in Django production is not just a matter of installing Celery and pointing it at Redis.
Problem statement
Running background jobs in Django production is not just a matter of installing Celery and pointing it at Redis. Your web app, worker processes, broker, and scheduler all have different failure modes and restart behavior.
A naive setup often breaks in predictable ways:
- Redis is exposed publicly or left unauthenticated
- Celery settings are hardcoded in Django settings
- workers run in a shell session instead of a service manager
- Celery Beat runs more than once and creates duplicate scheduled jobs
- long tasks block short tasks in the same queue
- deploys restart workers at the wrong time and leave queued or in-flight tasks in a bad state
If you want Django, Celery, and Redis to work reliably in production, treat them as a separate service stack: web, worker, broker, optional scheduler, and verification.
Quick answer
A safe default production architecture is:
- Django web app running under Gunicorn or Uvicorn
- Redis on a private interface or managed private service
- one or more Celery worker services
- one Celery Beat service only if you use periodic tasks
- broker URL and related settings loaded from environment variables
- systemd or containers used to keep worker processes running
- JSON-only serialization, task time limits, and intentional queue design
Minimum safe setup:
- Install and secure Redis
- Configure Celery in your Django project
- Run workers with systemd or containers
- Run Beat once only if needed
- Verify Redis connectivity, worker registration, and test task execution
- Define a rollback path before changing worker code or schedules
Step-by-step solution
Choose the production architecture
For small apps, a single Linux server can run:
- Django web service
- Redis bound to localhost or a private IP
- Celery worker
- optional Celery Beat
For larger or more sensitive setups, split Redis onto a private internal host or managed Redis service and keep workers on app hosts.
Docker and non-Docker both work. Use the one you already deploy consistently. If your web app uses systemd and a virtualenv, do not introduce Docker only for Celery unless you want the extra operational overhead.
Install and secure Redis for production
On Ubuntu:
sudo apt update
sudo apt install -y redis-server
Edit /etc/redis/redis.conf:
bind 127.0.0.1
protected-mode yes
port 6379
requirepass change-this-to-a-long-random-secret
appendonly yes
save 900 1
save 300 10
save 60 10000
maxmemory-policy noeviction
Notes:
bind 127.0.0.1keeps Redis local on a single-server setup.- If Redis is on another host, bind it to a private interface only, not
0.0.0.0. requirepassis acceptable for straightforward deployments. On newer Redis versions, ACLs are also an option.appendonly yesimproves recovery after restart, but it does not guarantee zero task loss.noevictionis safer than silently evicting broker data under memory pressure.
Restart Redis:
sudo systemctl restart redis-server
sudo systemctl enable redis-server
Verification:
redis-cli -a 'change-this-to-a-long-random-secret' ping
Expected output:
PONG
If Redis is remote, also restrict network access with a firewall or security group so only app and worker hosts can connect. If you use a managed Redis service, prefer TLS if connections leave the local host or private trusted network.
Configure Django and Celery
Install dependencies in your app environment:
pip install celery redis
Create proj/celery.py:
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "proj.settings.production")
app = Celery("proj")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
Update proj/__init__.py:
from .celery import app as celery_app
__all__ = ("celery_app",)
Example task in myapp/tasks.py:
from celery import shared_task
import logging
logger = logging.getLogger(__name__)
@shared_task
def healthcheck_task():
logger.info("Celery test task executed")
return "ok"
In settings/production.py:
import os
CELERY_BROKER_URL = os.environ["CELERY_BROKER_URL"]
# Only set a result backend if you actually need task results.
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND")
CELERY_RESULT_EXPIRES = 86400
CELERY_ACCEPT_CONTENT = ["json"]
CELERY_TASK_SERIALIZER = "json"
CELERY_RESULT_SERIALIZER = "json"
CELERY_TIMEZONE = "UTC"
CELERY_ENABLE_UTC = True
CELERY_TASK_ALWAYS_EAGER = False
CELERY_TASK_ACKS_LATE = True # use only for idempotent tasks; a task may run again after worker failure
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TASK_TIME_LIMIT = 1800
CELERY_TASK_SOFT_TIME_LIMIT = 1500
CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True
Environment file example:
DJANGO_SETTINGS_MODULE=proj.settings.production
CELERY_BROKER_URL=redis://:change-this-to-a-long-random-secret@127.0.0.1:6379/0
CELERY_RESULT_BACKEND=redis://:change-this-to-a-long-random-secret@127.0.0.1:6379/1
Verification:
python manage.py shell -c "from myapp.tasks import healthcheck_task; print(healthcheck_task.delay().id)"
If this errors before workers are started, it still confirms Django can publish to Redis.
Configure production Celery settings
For safer defaults in production:
CELERY_TASK_ACKS_LATE = Truehelps avoid losing work if a worker dies mid-task, but only use it for idempotent tasksCELERY_WORKER_PREFETCH_MULTIPLIER = 1reduces one worker reserving too many tasks- set soft and hard time limits for untrusted or long-running jobs
- use separate queues for long-running tasks
Example queue routing:
CELERY_TASK_ROUTES = {
"myapp.tasks.generate_report": {"queue": "long"},
}
Then run a separate worker for the long queue if needed.
Do not enable a result backend just by habit. If your app only fires jobs and logs side effects, skipping results reduces Redis load. If you do use Redis as a result backend, set an expiry so old task results do not accumulate indefinitely.
Run Celery workers in production
Create /etc/default/celery-proj:
DJANGO_SETTINGS_MODULE=proj.settings.production
CELERY_BROKER_URL=redis://:change-this-to-a-long-random-secret@127.0.0.1:6379/0
CELERY_RESULT_BACKEND=redis://:change-this-to-a-long-random-secret@127.0.0.1:6379/1
PATH=/srv/proj/venv/bin
Create /etc/systemd/system/celery-worker.service:
[Unit]
Description=Celery Worker for proj
After=network.target redis-server.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv/proj/current
EnvironmentFile=/etc/default/celery-proj
ExecStart=/srv/proj/venv/bin/celery -A proj worker --loglevel=INFO --concurrency=2
Restart=always
RestartSec=5
TimeoutStopSec=600
[Install]
WantedBy=multi-user.target
If you use periodic tasks, create /etc/systemd/system/celery-beat.service:
[Unit]
Description=Celery Beat for proj
After=network.target redis-server.service
[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/srv/proj/current
EnvironmentFile=/etc/default/celery-proj
ExecStart=/srv/proj/venv/bin/celery -A proj beat --loglevel=INFO
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Load and start:
sudo systemctl daemon-reload
sudo systemctl enable --now celery-worker
sudo systemctl status celery-worker
If using Beat:
sudo systemctl enable --now celery-beat
sudo systemctl status celery-beat
Log checks:
journalctl -u celery-worker -n 100 --no-pager
journalctl -u celery-beat -n 100 --no-pager
Environment-safe verification:
set -a
. /etc/default/celery-proj
set +a
/srv/proj/venv/bin/celery -A proj inspect ping
/srv/proj/venv/bin/celery -A proj inspect active
Docker production variant
If you deploy with Compose, define separate worker and beat services:
services:
worker:
image: your-app-image:latest
command: celery -A proj worker --loglevel=INFO --concurrency=2
env_file:
- .env
restart: always
depends_on:
- redis
beat:
image: your-app-image:latest
command: celery -A proj beat --loglevel=INFO
env_file:
- .env
restart: always
depends_on:
- redis
redis:
image: redis:7
restart: always
volumes:
- ./redis.conf:/usr/local/etc/redis/redis.conf:ro
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
Example redis.conf:
bind 0.0.0.0
protected-mode yes
appendonly yes
requirepass your-long-random-password
maxmemory-policy noeviction
In container deployments, make sure Redis is reachable only on the internal container network or private network path you intend to use. Do not publish Redis publicly unless you have a specific reason and matching network controls.
Important: depends_on does not guarantee application readiness. Your worker still needs retry behavior on startup, which Celery supports with CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True.
Run Beat only once in the whole environment.
Connect periodic tasks safely
Use Celery Beat only if you have scheduled jobs. Never run multiple Beat instances against the same schedule unless you are intentionally using a scheduler with distributed locking.
If schedules are simple and version-controlled, define them in code. If non-developers need to manage schedules, django-celery-beat can be appropriate, but it adds another database-backed component to operate.
Verification checks
After deployment, verify in order:
- Redis connectivity:
redis-cli -a "$REDIS_PASSWORD" ping - Worker registration:
set -a . /etc/default/celery-proj set +a celery -A proj inspect ping - Task execution:
python manage.py shell -c "from myapp.tasks import healthcheck_task; r=healthcheck_task.delay(); print(r.id)" - Logs:
journalctl -u celery-worker -n 100 --no-pager
Look for connection failures, permission errors, serializer errors, or import errors.
Explanation
Redis is common for Celery because it is simple to operate and fast enough for many Django background job workloads. For small and medium apps, it is usually enough as a broker and sometimes as a result backend.
However, Redis is not the right answer for every queueing problem. If you need more complex broker semantics, stronger delivery guarantees, or very large-scale queueing patterns, another broker may fit better. For many Django teams, though, Redis gives a practical balance between operational complexity and reliability.
Running workers separately from the web process matters because background tasks have different concurrency and timeout behavior. A task worker should be able to restart, scale, and fail independently from Gunicorn. Beat also must be independent because it is a scheduler, not an HTTP process.
Rollback and recovery
If a worker deploy is bad:
- Stop Beat first so it does not enqueue new scheduled jobs.
- Check whether workers still have active or reserved tasks before restarting them.
- Restart workers back to the previous code or image.
- Verify workers can consume the expected queues.
- Re-enable Beat only after the queue and logs look healthy.
Systemd example:
sudo systemctl stop celery-beat
set -a
. /etc/default/celery-proj
set +a
/srv/proj/venv/bin/celery -A proj inspect active
/srv/proj/venv/bin/celery -A proj inspect reserved
sudo systemctl restart celery-worker
A few deploy rules matter here:
- if new code enqueues tasks that old workers do not understand, rollback can still fail
- if new workers expect a schema change, apply migrations before they consume migration-dependent tasks
- if you use
acks_late, interrupted tasks may run again after worker failure or restart - tasks should be idempotent so retries or duplicate execution do not corrupt data
TimeoutStopSec=600 gives workers time to finish work during shutdown, but it is not a substitute for task design. Very long-running tasks may still need separate queues, longer shutdown windows, or a different execution strategy.
If Redis restarts and transient tasks are lost, recovery depends on your persistence mode and whether tasks can be recreated safely. For critical workflows, tasks should be idempotent and externally recoverable where possible.
When to automate this setup
Once you repeat this process across environments, convert the Celery files and service definitions into reusable templates. Good candidates are celery.py, hardened production settings, systemd unit files, environment file generation, and post-deploy smoke tests. It is also worth scripting Beat pause and resume around deploys.
Edge cases and notes
- Long-running jobs: keep them on a separate queue so they do not block short tasks.
- CPU-bound tasks: Celery concurrency helps less for heavy CPU work; tune worker count carefully based on available cores.
- Migrations during deploys: if a task depends on a new schema, apply migrations before restarting workers onto code that enqueues or consumes those tasks.
- Same server as Gunicorn: acceptable for smaller apps, but cap worker concurrency so Celery does not starve the web app of CPU or memory.
- Static files: Celery does not change static handling directly, but your deploy order still matters: collect static, migrate, restart app, restart workers, then verify.
- Multi-instance deployments: only one Beat should run, but many worker instances can share the same queues.
- Remote Redis: private networking is the baseline; add TLS when traffic crosses hosts or leaves a trusted network.
- Result backend growth: if you keep task results in Redis, use expiry and monitor memory usage.
Internal links
To build the full production picture, also read:
- Django Deployment Checklist for Production
- Deploy Django with Gunicorn and Nginx on Ubuntu
- Deploy Django ASGI with Uvicorn and Nginx
- How to debug stuck Celery tasks in production
FAQ
Can I run Celery and Gunicorn on the same server?
Yes, for small apps. Keep Redis private, use systemd or containers, and limit Celery concurrency so the worker does not consume all CPU or memory.
Do I need Celery Beat in production?
Only if you run scheduled tasks. If you do, run exactly one Beat instance for that environment.
Should Redis be used as both broker and result backend?
It can be, but only enable the result backend if your app actually needs task state or returned values. Many production setups use Redis only as a broker.
How many Celery workers should I run?
Start with one worker service and a conservative concurrency value such as 2. Increase based on task type, CPU, memory, and queue backlog. Separate long-running tasks into their own queue before scaling blindly.
What happens to queued tasks during a deploy?
Queued tasks usually remain in Redis if the broker stays up. In-progress tasks may be retried depending on acknowledgment timing and worker shutdown behavior. That is why tasks should be idempotent and deploys should include worker restart and verification steps.