Deploying to Production
The huey consumer is a normal, foreground Python process. It does not daemonize, write pid-files, or manage its own lifecycle - that is the job of a process supervisor like systemd, supervisord, Docker, or your PaaS. This document provides correct, copy-paste configurations for the common supervisors, along with a production checklist.
The configuration files shown below are also available in the examples/deploy directory of the huey source tree.
Shutdown Signals
The consumer responds to the following signals:
Signal |
Consumer behavior |
|---|---|
|
Graceful shutdown. Workers finish their current task, then the process exits. |
|
Immediate shutdown. Running tasks are interrupted, and
|
|
Graceful restart. Workers finish their current task, then the consumer re-executes itself in-place. |
Nearly every process supervisor stops processes with SIGTERM by default,
which huey treats as stop immediately. If you take nothing else from this
page: configure your supervisor to stop huey with SIGINT, and give it
enough time for in-flight tasks to finish before escalating. Every example
below includes the appropriate setting.
Two related points:
A graceful shutdown protects running tasks. A task interrupted by
SIGKILL(or a power loss) is lost. Set the stop-timeout to comfortably exceed your longest-running task, register aSIGNAL_INTERRUPTEDhandler to re-enqueue interrupted tasks (Graceful Shutdown and Re-enqueueing Interrupted Tasks). As with any queue, design tasks to be idempotent wherever possible.Do not wrap the consumer in a shell script. The supervisor must signal the Python process directly; an intermediate shell will interfere with signal delivery.
systemd
[Unit]
Description=huey consumer for my_app
After=network.target redis.service
[Service]
Type=exec
User=appuser
Group=appuser
WorkingDirectory=/srv/my_app
# Run the consumer directly -- no shell-script wrappers -- so that signals
# are delivered to the Python process. See github issue #88.
ExecStart=/srv/my_app/venv/bin/huey_consumer my_app.huey -w 4 -k thread
# Huey shuts down gracefully on SIGINT, allowing workers to finish their
# current task. systemd's default KillSignal is SIGTERM, which huey treats
# as "stop immediately, interrupting any running tasks" -- so override it.
KillSignal=SIGINT
# How long to wait for in-flight tasks to finish before escalating to
# SIGKILL. Set this to comfortably exceed your longest-running task.
TimeoutStopSec=60
# "systemctl reload huey" maps to huey's graceful restart (SIGHUP). The
# consumer re-executes itself in-place, keeping the same PID.
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Install the unit and start it:
sudo cp huey.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now huey
Notes:
journald captures stdout/stderr, so run the consumer without the
-llogfile option and read logs withjournalctl -u huey.systemctl reload hueytriggers huey’s graceful restart (SIGHUP): the consumer re-executes itself in-place, keeping the same PID. AvoidType=forking; the unit’sType=execis correct, and also surfaces launch errors at startup.Restart=on-failurerestarts the consumer after a crash, but leaves it stopped after a clean exit (e.g. a gracefulkill -INT). UseRestart=alwaysto bring it back regardless.systemctl stopnever triggers an automatic restart with either setting.
supervisord
[program:huey]
directory=/srv/my_app
; Run the consumer directly -- no shell-script wrappers -- so that signals
; are delivered to the Python process. See github issue #88.
command=/srv/my_app/venv/bin/huey_consumer my_app.huey -w 4 -k thread
user=appuser
autostart=true
autorestart=true
; Huey shuts down gracefully on SIGINT, allowing workers to finish their
; current task. Supervisor's default stopsignal is TERM, which huey treats
; as "stop immediately, interrupting any running tasks" -- so use INT.
stopsignal=INT
; How long to wait for in-flight tasks to finish before escalating to
; SIGKILL. Set this to comfortably exceed your longest-running task.
stopwaitsecs=60
stdout_logfile=/var/log/huey/stdout.log
stderr_logfile=/var/log/huey/stderr.log
environment=PYTHONUNBUFFERED="1"
Notes:
If your application module is not on the python-path, add e.g.
environment=PYTHONPATH="/srv/my_app"or setdirectoryto the project root (the consumer is run fromdirectory).After editing the config,
supervisorctl reread && supervisorctl update.
Docker
FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=1
# "docker stop" sends SIGTERM by default, which huey treats as "stop
# immediately, interrupting any running tasks". Huey's graceful-shutdown
# signal is INT.
STOPSIGNAL SIGINT
# Use the exec form (JSON array, no shell) so that signals are delivered
# to the consumer rather than to an intermediate shell.
CMD ["huey_consumer", "my_app.huey", "-w", "4", "-k", "thread"]
Notes:
STOPSIGNAL SIGINTmakesdocker stoprequest a graceful shutdown. The default grace period is only 10 seconds, however, so stop withdocker stop -t 60 <container>(or setstop_grace_periodin compose) to give in-flight tasks time to finish.Always use the exec form of
CMD(the JSON-array form, with no shell), so the consumer runs as PID 1 and receives signals directly.Log to stdout (no
-l) and let the logging driver handle collection and rotation.The
processworker type works fine in containers. Forgreenletworkers, remember the monkey-patch must be applied at the top of your entry module - see Consuming Tasks.
Docker Compose
services:
redis:
image: redis:7-alpine
volumes:
- redis-data:/data
web:
build: .
command: ["gunicorn", "my_app.wsgi"]
environment:
REDIS_HOST: redis
depends_on:
- redis
# The worker is built from the same image as the web app, so both see the
# same code and the same task registry.
worker:
build: .
# NOTE: if you scale this service, add "-n" here and run a single
# separate service (without "-n") to enqueue periodic tasks.
command: ["huey_consumer", "my_app.huey", "-w", "4"]
environment:
REDIS_HOST: redis
depends_on:
- redis
# Time allowed for a graceful shutdown (SIGINT, via the image's
# STOPSIGNAL) before the container is killed. Set this to comfortably
# exceed your longest-running task.
stop_grace_period: 60s
volumes:
redis-data:
Notes:
The web app and the worker share one image, so both processes import the same code and the same task registry (see Understanding how tasks are imported).
Scaling the worker service (
docker compose up --scale worker=3) runs multiple consumers against one queue, and each will independently enqueue periodic tasks. Run a single dedicated consumer for periodic tasks and start the scaled workers with-n/--no-periodic. See Multiple Consumers.Multiple containers can only share a queue through a network-accessible storage backend like Redis.
SqliteHueyandFileHueywork across containers only if every container mounts the same local volume, and sqlite over a network filesystem is a bad idea. When in doubt, use Redis.
Kubernetes
A minimal worker Deployment fragment:
spec:
containers:
- name: huey-worker
image: my-app:latest
command: ["huey_consumer", "my_app.huey", "-w", "4", "-n"]
terminationGracePeriodSeconds: 60
The things that matter:
Kubernetes honors the image’s
STOPSIGNAL, so the Dockerfile above gets graceful shutdown for free. Without it, the kubelet sendsSIGTERMand running tasks are interrupted. (Alternatively, use alifecycle.preStophook, or simply rely on theSIGNAL_INTERRUPTEDre-enqueue recipe.)terminationGracePeriodSecondsis the SIGKILL deadline – set it to comfortably exceed your longest-running task.With
replicas > 1, periodic tasks must only be enqueued by one consumer. The simple pattern: a scalable worker Deployment started with-n/--no-periodic(as above), plus a single-replica “scheduler” Deployment running without-n.
PaaS (Heroku-style)
# Procfile
web: gunicorn my_app.wsgi
worker: huey_consumer my_app.huey -w 4 -k thread
Dyno-style process managers send SIGTERM with a short grace period
(typically ~30 seconds) and offer no way to customize the signal, so running
tasks will be interrupted on every deploy and restart. Registering the
SIGNAL_INTERRUPTED re-enqueue handler (Graceful Shutdown and Re-enqueueing Interrupted Tasks)
is essential on these platforms. Read the storage location from the
environment:
import os
from huey import RedisHuey
huey = RedisHuey('my-app', url=os.environ['REDIS_URL'])
Logging
Under systemd, Docker, or a PaaS, log to stdout (the default when no -l
option is given) and let the platform capture it.
When supervising the consumer some other way, use -l /var/log/huey.log
and configure rotation. The consumer holds its logfile open and has no
reopen-on-signal mechanism, so use copytruncate:
# /etc/logrotate.d/huey
/var/log/huey.log {
weekly
rotate 8
compress
copytruncate
missingok
}
Health checks
The consumer monitors its own workers and restarts any that die (see the
-c / --health-check-interval option), so an external liveness check
mainly needs to verify the process is up and the storage backend reachable.
A trivial exec-style probe:
# huey_health.py - exits non-zero if the storage backend is down.
from my_app import huey
huey.pending_count()
For queue-depth monitoring and a web-based health endpoint, see Monitoring Queue Depth.
Deploying new code
The consumer caches your task code in memory, so deploys must restart (or gracefully re-exec) the consumer:
systemctl reload huey/kill -HUP <pid>- graceful in-place restart: workers finish their current task, then the consumer re-executes itself, picking up the new code.Or stop gracefully (
SIGINT) and start a new consumer - this is what the supervisor configs above do onrestart.For very long-running tasks, you can run old and new code side-by-side by giving the new release a fresh storage
name- see Deployments.
Production checklist
Stop signal is
INTand the stop-timeout exceeds your longest task (Shutdown Signals).A
SIGNAL_INTERRUPTEDhandler re-enqueues interrupted tasks, or your tasks are idempotent (Graceful Shutdown and Re-enqueueing Interrupted Tasks).Exactly one consumer enqueues periodic tasks; all others run with
-n(Multiple Consumers).The consumer is run directly - no shell-script wrappers.
Result data is read (or expired) so the result store does not grow without bound - read results, set
expires=, or useRedisExpireHuey. See Troubleshooting and Common Pitfalls.If you use
lock_task(), start the consumer with-f/--flush-locksso locks orphaned by a crash are cleared.Worker count and worker type match the workload (Worker types).
immediatemode is disabled in production (it is the default, but Django users should double-check, since djhuey enables it whenDEBUG=True).If the storage backend is shared or network-exposed, messages are signed with
SignedSerializer(Signed Serializer for Untrusted Environments).Logs go to stdout under systemd/Docker/PaaS, or are rotated with
copytruncatewhen using-l.