You dockerise a Laravel app, ship it, and the scheduled tasks just never run. No error, no log line, nothing. The web container serves traffic fine and the queue worker churns through jobs, but schedule:run never fires. The reason is simple: the container has no crontab, and the usual fix — bolting cron on as PID 1 — swallows your logs and dies quietly. Here are the two ways I actually run the Laravel scheduler in Docker: supercronic for a plain container, and a Kubernetes CronJob when I'm already on a cluster.
Why host cron breaks inside a Docker container#
The php artisan schedule:run command is designed to be called every minute. On a VM you add one line to the host crontab:
* * * * * cd /var/www && php artisan schedule:run >> /dev/null 2>&1
That line assumes three things a container doesn't give you: a running cron daemon, a crontab to install it into, and an init system to reap child processes. A slim php:8.4-fpm or FrankenPHP image ships none of them.
The tempting fix is to apt-get install cron, drop your crontab in, and start cron in the foreground as the container's main process. It works until it doesn't:
- Traditional cron wipes the environment before running a job, so your container's
ENVvars vanish andschedule:runruns with a different config than your app. - cron writes job output to a local mailbox or syslog, not stdout. In a container that means your task output goes nowhere — when a scheduled job throws, you find out from the symptom, not a log line.
- As PID 1, cron doesn't forward
SIGTERMto its children, sodocker stopwaits out the grace period and then SIGKILLs a job mid-run. - If the cron daemon itself dies, the container keeps running. There's no liveness probe catching it the way you'd catch a dead web process, so the scheduler is silently dead until someone notices the nightly report never arrived.
Option 1: Run the Laravel scheduler with supercronic#
supercronic is a crontab-compatible runner built for containers. It reads a normal crontab file, runs jobs in the foreground, logs every job to stdout/stderr, inherits the container's environment, and shuts down gracefully on SIGTERM. It ships as a single static binary — exactly what you want in an image.
Start with a one-line crontab. Create docker/scheduler/crontab:
# Run Laravel's scheduler every minute — no output redirect, we want the logs
* * * * * cd /var/www/html && php artisan schedule:run
That's the whole crontab. Laravel's scheduler does the real work: schedule:run checks every task you've defined in routes/console.php and runs whatever is due this minute. supercronic just guarantees it's called once a minute with your environment intact.
Now install supercronic in your image. This stanza comes straight from the releases page — always pin the version and checksum rather than pulling latest:
# curl must already be installed in the base image.
# Install supercronic — pin the version and SHA from the releases page.
ENV SUPERCRONIC_URL=https://github.com/aptible/supercronic/releases/download/v0.2.45/supercronic-linux-amd64 \
SUPERCRONIC_SHA1SUM=e894b193bea75a5ee644e700c59e30eedc804cf7 \
SUPERCRONIC=supercronic-linux-amd64
RUN curl -fsSLO "$SUPERCRONIC_URL" \
&& echo "${SUPERCRONIC_SHA1SUM} ${SUPERCRONIC}" | sha1sum -c - \
&& chmod +x "$SUPERCRONIC" \
&& mv "$SUPERCRONIC" "/usr/local/bin/${SUPERCRONIC}" \
&& ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic
COPY docker/scheduler/crontab /etc/crontab
# supercronic becomes PID 1, so SIGTERM is handled cleanly on shutdown
CMD ["supercronic", "/etc/crontab"]
On arm64 (Apple Silicon, Graviton) swap the URL and SHA for the supercronic-linux-arm64 stanza on the same releases page.
I run this as its own image target, not baked into the web container. If you're already using a multi-stage build to keep the app image slim, add a scheduler stage that reuses the same PHP base and vendor layer, then override the command. One codebase, three runtime containers: web, queue worker, scheduler.
When a task runs, supercronic prints the schedule, the command, and the exit code to stdout. That's the whole point: your scheduler output lands in the same place as everything else, ready for a centralized pipeline like Grafana Loki to pick up. No mailbox, no syslog, no guessing.
Option 2: A Kubernetes CronJob for the scheduler#
On Kubernetes you can skip the cron-in-a-container layer entirely. The cluster already has a scheduler — the control plane — so hand it a CronJob that runs schedule:run every minute:
apiVersion: batch/v1
kind: CronJob
metadata:
name: laravel-scheduler
spec:
schedule: "* * * * *" # every minute
concurrencyPolicy: Forbid # never overlap runs
successfulJobsHistoryLimit: 1
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 0 # a missed minute is fine, don't retry
template:
spec:
restartPolicy: Never
containers:
- name: scheduler
image: registry.example.com/my-app:latest
command: ["php", "artisan", "schedule:run"]
envFrom:
- secretRef:
name: my-app-env
Two settings carry their weight here. concurrencyPolicy: Forbid tells Kubernetes to skip a run if the previous one is still going, so a slow task never stacks up against the next minute. backoffLimit: 0 stops Kubernetes retrying a failed minute — the scheduler runs again in sixty seconds anyway, and a retry storm on a broken task helps no one.
The tradeoff: schedule:run boots a fresh PHP process and full framework bootstrap every single minute. On a small cluster that's noise. But if your app is heavy to boot or you're tightly packed on resources, the always-on supercronic container is lighter because the process stays warm. I reach for the CronJob when I'm already running the app as a Deployment and Pods on the cluster and want one less long-lived process to babysit.
Don't run two schedulers#
This is the one that bites people. schedule:run has no built-in "only me" guard across processes. Run the scheduler container with two replicas and every due task fires twice — two nightly emails, two billing runs, two of whatever you least want doubled.
The rule is blunt: run exactly one scheduler instance. A scheduler isn't a queue worker. Queue workers you scale out horizontally — more replicas, more throughput. The scheduler is a singleton. Keep its Deployment at replicas: 1, or let the Kubernetes CronJob be the single source of truth.
If you genuinely can't guarantee a single instance — say a rolling deploy briefly overlaps two scheduler pods — guard the individual tasks with onOneServer():
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('reports:send')
->daily()
->onOneServer(); // first server to grab the atomic lock wins
onOneServer() takes an atomic cache lock before running, so only the first instance to acquire it executes the task. It needs a shared, lock-capable cache driver — Redis, Memcached, or DynamoDB — not the local file or array driver, or every container holds its own lock and the guard does nothing. Treat it as a seatbelt, not a licence to run five schedulers. If a single long-running task can overlap itself, reach for withoutOverlapping() too — different problem, same spirit.
Wrapping Up#
Pick supercronic for a plain Docker or Compose setup: one small container, one crontab line, logs on stdout. Pick a Kubernetes CronJob when you're already on a cluster and want the control plane to own the timing. Either way, run exactly one scheduler and let schedule:run fan the work out.
From here the natural next steps are wiring up health checks so a dead scheduler actually pages someone, and giving your background workers the same dedicated-container treatment with a memory-bounded queue worker.
FAQ#
How do I run the Laravel scheduler inside a Docker container?
Run a dedicated container whose command calls php artisan schedule:run once a minute. The cleanest way is supercronic: install the static binary, give it a one-line crontab, and set it as the container's command. It runs in the foreground, inherits your environment, and logs to stdout. Keep it as a separate container from your web and queue-worker processes so each can be scaled and restarted independently.
Why doesn't cron work in my Laravel Docker image?
Slim PHP images don't ship a cron daemon, a crontab, or an init system, so there's nothing to fire schedule:run. Even when you install cron by hand, it wipes the environment before running jobs (losing your container's config), sends output to a mailbox instead of stdout, and doesn't handle SIGTERM cleanly as PID 1. That's why a purpose-built runner like supercronic, or a Kubernetes CronJob, is the better fit for containers.
Should I use a Kubernetes CronJob for the Laravel scheduler?
If you're already on Kubernetes, yes — it's the idiomatic choice. A CronJob with schedule: "* * * * *" and concurrencyPolicy: Forbid runs schedule:run every minute without you maintaining a long-lived cron container. The one cost is a fresh PHP bootstrap each minute; if your app is slow to boot, an always-on supercronic container stays warmer and cheaper.
How do I stop scheduled tasks running twice across multiple containers?
Run only one scheduler instance — the scheduler is a singleton, not something you scale horizontally like queue workers. Keep its Deployment at replicas: 1 or rely on a single Kubernetes CronJob. If overlap is possible during deploys, add onOneServer() to individual tasks so only the instance that grabs the atomic lock runs them; this requires a shared cache driver such as Redis.
What is supercronic and why use it with Laravel?
supercronic is a crontab-compatible job runner built specifically for containers. Unlike traditional cron, it runs in the foreground, keeps your environment variables, logs job output and exit codes to stdout/stderr, and shuts down gracefully on SIGTERM. For Laravel that gives you a single reliable process to call schedule:run every minute, with logging that matches how containers actually work.