from Supervisor to Graceful Shutdown
Anyone who drops cronjobs and worker processes into an existing Docker container without a plan will sooner or later run into PID 1 zombies, lost signals and uncontrolled restarts. Dedicated containers, supervisor and clean signal handling replace fragile setups with predictable, production ready background processes.
Table of Contents
- 1. Why Cronjobs in Docker Need Their Own Strategy
- 2. The PID 1 Problem and How to Solve It
- 3. Supervisor: Controlling Multiple Processes in One Container
- 4. Dedicated Worker Containers as the Better Alternative
- 5. Cron Containers: Managing Cronjobs Cleanly
- 6. Signal Handling and Graceful Shutdown
- 7. Health Checks for Worker and Cron Containers
- 8. Aggregating Logs from Worker Containers Cleanly
- 9. Strategies Compared Side by Side
- 10. Summary
- 11. FAQ
1. Why Cronjobs in Docker Need Their Own Strategy
Anyone who knows cronjobs from a classic Linux environment will typically type the familiar crontab into a Dockerfile first, then wonder why the process inside the container does not behave as expected. The reason lies in the very nature of Docker containers: a container is designed to run exactly one main process. The classic Docker cronjob approach, simply starting crond as a background process alongside the actual application, leads to problems with signal forwarding, zombie processes and uncontrollable restarts.
On top of that comes the container context: environment variables passed via docker run -e or Compose files are not available to the crond daemon, because cron starts its own minimal environment. A Docker worker or cronjob has to read these variables explicitly. In practice this produces hard to reproduce bugs, where cronjobs work locally but stay silent in staging. The fix is a thoughtful architecture that accounts for the container's nature from the very start.
2. The PID 1 Problem and How to Solve It
In every Docker container, the first process to start takes on PID 1. Normally that is the process launched by CMD or ENTRYPOINT in the Dockerfile. PID 1 carries a special responsibility on Linux: it must reap zombie child processes and forward signals correctly to child processes. A plain shell script or a worker process that was never designed to act as an init process cannot fulfill that role. The result is zombie processes eating up resources and signals such as SIGTERM never reaching the actual worker process.
The cleanest solution to this problem when running Docker cronjobs and workers is tini. Tini is a minimal init process that runs as PID 1, performs zombie reaping and forwards signals correctly. In modern Docker versions, tini can be enabled with the --init flag on the docker run call. Alternatively, tini can be baked directly into the image. Another option is Yelp's dumb-init, which handles the same tasks and is especially popular in Python based worker images. Without one of these helpers, any Docker setup running background processes is built on shaky ground.
# Dockerfile for a worker container with tini as init process
FROM php:8.4-cli-alpine
# Install tini for correct PID-1 signal handling
RUN apk add --no-cache tini
# Copy worker script and set permissions
COPY worker.php /app/worker.php
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
WORKDIR /app
# Use tini as PID 1, ensures SIGTERM is forwarded and zombies are reaped
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/entrypoint.sh"]
3. Supervisor: Controlling Multiple Processes in One Container
When several closely related processes need to run in one container, for example a PHP application and its associated queue worker, Supervisor is the proven tool for Docker worker setups. Supervisor takes on the role of the init process, starts the configured programs, monitors them and restarts them automatically on crash. Configuration happens in supervisord.conf files copied into the Docker image. Supervisor also solves the PID 1 problem, since it correctly acts as an init process.
A Docker cronjob configuration under Supervisor looks like a regular program with command=, autostart=true, autorestart=true and defined restart delays. For queue workers meant to run indefinitely, autorestart=unexpected is a good fit: Supervisor restarts the worker when it exits with an error code, but not on a clean exit (exit code 0). For cronjobs, on the other hand, which run once and then finish, a separate architecture without Supervisor is recommended, since Supervisor does not understand native cron syntax.
# /etc/supervisor/conf.d/worker.conf
# Supervisor configuration for queue worker and cron in one container
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
[program:queue-worker]
# Queue worker, runs continuously, restarts on unexpected exit
command=php /app/bin/magento queue:consumers:start async.operations.all
directory=/app
autostart=true
autorestart=unexpected
startretries=3
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment=APP_ENV="production",WORKER_ID="%(process_num)s"
numprocs=2
process_name=%(program_name)s_%(process_num)02d
[program:cron-runner]
# Lightweight cron script, executes every minute via busybox cron
command=/usr/sbin/crond -f -l 8
autostart=true
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
4. Dedicated Worker Containers as the Better Alternative
An architecture built on dedicated Docker worker containers is preferable to the Supervisor approach in most cases. Instead of cramming several processes into one container, each worker type gets its own container. This follows Docker's single responsibility principle and makes it possible to scale, deploy and monitor workers independently. A queue worker for sending email can be scaled to three instances while a PDF generation worker stays at one, without either change requiring modifications to the same container image.
In a Docker Compose configuration, Docker workers are defined as separate services that use the same base image but have different command values. The base image contains the full application code, and the entry point decides which process gets started. This structure also enables rolling updates of individual worker types without having to restart the main application container. In Kubernetes environments, this corresponds to Deployment objects with different command values.
5. Cron Containers: Managing Cronjobs Cleanly
For Docker cronjobs, a dedicated cron container is recommended, one that runs the cron daemon exclusively as PID 1 and holds the cronjob definitions in a configurable crontab file. The most important detail here: environment variables from the Docker context must be explicitly passed on to the cron process. This works reliably with an entrypoint script that writes the current environment variables into a file, which the cron job then loads via source.
Alternatively, there are specialized tools such as ofelia or supercronic, built specifically for Docker cronjob setups. Supercronic is a cron replacement that offers container friendly logging (JSON), correct signal handling and explicit error handling. Ofelia manages cronjobs for every container in a Docker Compose stack via labels, similar to how Traefik handles HTTP routing. Both tools solve the environment variable problem out of the box and are therefore a much better fit for production Docker worker and cron setups than native crond.
# docker-compose.yml, dedicated cron and worker services
version: "3.9"
services:
app:
image: myapp:latest
environment:
APP_ENV: production
DB_HOST: db
# Dedicated queue worker, scales independently from the app
worker-queue:
image: myapp:latest
command: ["php", "bin/magento", "queue:consumers:start", "async.operations.all", "--max-messages=1000"]
restart: unless-stopped
depends_on:
- app
environment:
APP_ENV: production
DB_HOST: db
deploy:
replicas: 2
# Dedicated cron container using supercronic
cron:
image: myapp:latest
command: ["/usr/local/bin/supercronic", "/app/crontab"]
restart: unless-stopped
environment:
APP_ENV: production
DB_HOST: db
volumes:
- ./crontab:/app/crontab:ro
# /app/crontab (supercronic format, same as standard cron)
# */5 * * * * php /app/bin/magento indexer:reindex
# 0 2 * * * php /app/bin/magento catalog:images:resize
6. Signal Handling and Graceful Shutdown
Graceful shutdown is one of the most common weak points of Docker worker processes in production setups. When Docker stops a container, the engine first sends SIGTERM to PID 1 and waits ten seconds by default. If the process does not handle the signal or fails to forward it, SIGKILL follows. A worker that is in the middle of an important job gets interrupted abruptly. That can lead to inconsistent database states, jobs processed twice, or lost messages.
The solution is to implement the Docker worker process so that it catches SIGTERM, finishes the job currently in progress and then shuts down cleanly. In PHP this is implemented with pcntl_signal(SIGTERM, function() { $this->shouldStop = true; }) and a matching check inside the worker loop. In the Compose or Kubernetes configuration, stop_grace_period can also be set to a higher value, for example 30 or 60 seconds, so long running jobs have time to reach a clean stopping point before SIGKILL is enforced.
7. Health Checks for Worker and Cron Containers
Without health checks, Docker has no way of knowing whether a Docker worker container is actually doing work or stuck in a deadlock. The container status shows running, but the actual worker process may be hanging or long dead. Docker health checks make it possible to run a command inside the container on a regular basis and mark the container as unhealthy if that command fails. Orchestrators such as Kubernetes or Docker Swarm can then restart unhealthy containers automatically.
For Docker cronjob containers, a heartbeat based health check is the right pattern: the cronjob writes a timestamp to a file on every successful run. The health check verifies that the timestamp is no older than twice the cron interval. This way a hung or dead cron process gets detected and the container restarted. For workers running under Supervisor, the health check can query the Supervisor status via supervisorctl status and check for RUNNING.
# Dockerfile, health check for a queue worker container
FROM php:8.4-cli-alpine
RUN apk add --no-cache tini
COPY worker.sh /usr/local/bin/worker.sh
COPY healthcheck.sh /usr/local/bin/healthcheck.sh
RUN chmod +x /usr/local/bin/worker.sh /usr/local/bin/healthcheck.sh
# Worker writes a heartbeat timestamp on each successful iteration
# Health check verifies the heartbeat is not older than 120 seconds
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
CMD /usr/local/bin/healthcheck.sh
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/usr/local/bin/worker.sh"]
# --- healthcheck.sh ---
# #!/bin/sh
# HEARTBEAT_FILE="/tmp/worker_heartbeat"
# MAX_AGE=120
# if [ ! -f "$HEARTBEAT_FILE" ]; then exit 1; fi
# AGE=$(( $(date +%s) - $(stat -c %Y "$HEARTBEAT_FILE") ))
# [ "$AGE" -lt "$MAX_AGE" ] || exit 1
8. Aggregating Logs from Worker Containers Cleanly
An often underestimated aspect of running Docker workers and cron containers is logging. Unlike HTTP request handlers, worker processes generate log entries continuously, which quickly become hard to follow without structured logging. The Docker log model expects processes to write to stdout and stderr, any other log target (files, syslog) must be forwarded explicitly. The simplest approach is JSON structured logging written directly to stdout, which Docker then collects and forwards to log drivers such as Loki, Elasticsearch or CloudWatch.
With multiple Docker worker instances, correlating log entries across container IDs and worker IDs becomes crucial. Every log entry should include the container ID, a worker identifier, the job type and a trace ID for the job being processed. In practice this is solved with environment variables: the Compose configuration sets WORKER_ID, the worker process reads that value and attaches it to every log entry. That way, when something goes wrong, all entries for a specific job run can be filtered in Grafana or Kibana.
9. Strategies Compared Side by Side
The right architecture for Docker workers and cronjobs depends on the specific use case. Each approach has its own strengths and weaknesses worth knowing before designing the system.
| Strategy | Advantage | Drawback | Recommendation |
|---|---|---|---|
| Cron in the app container | Simple to set up | Missing env vars, zombie risk | Local development only |
| Supervisor | Multiple processes, auto restart | No native cron, more complex | Tightly coupled worker types |
| Dedicated worker container | Independently scalable, clear | More services in Compose | Production recommendation |
| Supercronic | JSON logging, correct signal handling | Cron workloads only | Cron container recommendation |
| Kubernetes CronJob | Native K8s integration | Requires Kubernetes | From medium scale onward |
In most production environments, the choice between Supervisor and dedicated containers falls in favor of dedicated containers, since they make full use of Docker's operational advantages: independent deployments, independent scaling and clear responsibilities. Supercronic and tini are useful in both variants and reliably solve the core technical problems, PID 1, signal handling and logging.
Mironsoft
Docker infrastructure, worker architectures and production deployments
Docker workers and cronjobs that actually run?
We analyze existing worker setups, identify fragile configurations and build production ready Docker worker and cron architectures with correct signal handling, health checks and structured logging.
Worker architecture
Dedicated containers, supervisor config and scaling concepts for queue workers
Signal handling
Graceful shutdown, tini integration and correct PID 1 configuration
Monitoring
Health checks, heartbeat monitoring and log aggregation for background processes
10. Summary
Running Docker cronjobs and workers correctly starts with solving the PID 1 problem using tini or dumb-init, so signals get forwarded correctly and zombie processes never appear in the first place. Dedicated containers per worker type are the cleaner approach compared to running several processes in one container, because they enable independent scaling, independent deployments and clear responsibilities. Supercronic solves cron's environment variable problem in Docker more elegantly than native crond.
Graceful shutdown through explicit signal handling in the worker code prevents abruptly interrupted jobs and inconsistent system states. Health checks based on heartbeat timestamps make the real operational state of a Docker worker container visible to orchestrators. Structured logging to stdout with worker ID and job trace ID enables debugging in production setups with multiple parallel worker instances.
Docker Cronjobs and Workers: The Essentials at a Glance
PID 1 problem
Use tini or dumb-init as PID 1: correct signal forwarding and zombie reaping without writing your own init code in the worker.
Dedicated containers
One worker type per container: independently scalable, independently deployable, clear responsibilities in the Compose stack.
Supercronic for cron
Container friendly cron replacement with JSON logging, correct signal handling and native env var support.
Health checks
Heartbeat timestamp plus HEALTHCHECK directive: the orchestrator detects hanging workers and restarts the container automatically.