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.
13 min read
The scheduler worked on the VPS: one crontab line, * * * * * cd /var/www && php artisan schedule:run, and the rest lived in routes/console.php. Then the app moved into Docker, and every
answer about where that line goes now disagrees with the last one. The Laravel docs say cron, and
mention schedule:work only for running the scheduler locally. A Laracasts
thread
says never use schedule:work outside development. Server Side
Up’s images
run schedule:work in its own container and say “we don’t use cron in containers.”
They can all be right, because the answer changed in Laravel 13.18. We ran each option on Laravel
13.38 and 12.70.1 (PHP 8.5), with the scheduler as PID 1 in its own PID namespace, the way Docker
runs it, and checked four things: whether the job sees the container’s environment, where its output
goes, what docker stop does to a task that’s running, and what happens when a task fails.
Short answer
On Laravel 13.18 or newer, run php artisan schedule:work as its own container from the same image
as the app, with init: true, the pcntl extension and a stop_grace_period longer than your
slowest task. It sees the container’s environment, prints to docker logs, and on docker stop it
waits for running tasks. On Laravel 12 or older, schedule:work dies on SIGTERM and takes the running
task with it, so run schedule:run from Supercronic instead. Debian’s cron inside the container is
the option that wipes your env vars and loses the output. Use Supervisor only if the scheduler has to
share a container with php-fpm.
Whichever you pick, schedule:run exits 0 and prints DONE when a scheduled command fails, and
throws the command’s output away. Add onFailure hooks, or replace schedule:work with php artisan runwisp:work from the PHP SDK of RunWisp, an open-source cron replacement that ships as a
single binary. It reads the schedule you already have in routes/console.php and records every
run’s output, exit code and duration. Setup below.
| Sees container env | Task output in docker logs | Running task finishes on stop | Runner sees a failed task | Schedule stays in PHP | |
|---|---|---|---|---|---|
Debian cron + schedule:run | ✗ | ✗ | ✗ | ✗ | ✓ |
Supercronic + schedule:run | ✓ | ✗ | ✓ | ✗ | ✓ |
schedule:work container (13.18+) | ✓ | ✗ | ✓ | ✗ | ✓ |
schedule:work container (≤ 12) | ✓ | ✗ | ✗ | ✗ | ✓ |
Supervisor + schedule:work | ✓ | ✗ | 13.18+ only | ✗ | ✓ |
RunWisp runwisp:work (PHP SDK) | ✓ | ✗ | ✓ | ✓ | ✓ |
“Task output” means the command’s own output, not the scheduler’s one-line summary. Every Laravel
row can get it with appendOutputTo('/proc/1/fd/1') (below); RunWisp
keeps it in its web UI instead. “Finishes on stop” also needs a stop_grace_period longer than the
task, and pcntl for schedule:work and runwisp:work.
1. Debian cron inside the app container
The direct port of the VPS setup: install cron in the image and add the crontab line.
FROM php:8.5-cli
RUN apt-get update && apt-get install -y --no-install-recommends cron \
&& rm -rf /var/lib/apt/lists/*
COPY . /var/www
RUN echo '* * * * * root cd /var/www && php artisan schedule:run' > /etc/cron.d/laravel
CMD ["cron", "-f"]
It runs, and three things go wrong.
The environment is gone. cron starts each job with HOME, PATH, SHELL and little else.
Everything you set under environment: in compose is missing, and Laravel falls back to the defaults
in config/database.php. We printed DB_HOST from a command, once with the container’s environment
and once under env -i, which is what cron gives a job:
container env: db_host='db' cfg_host=db
cron-like env: db_host=NULL cfg_host=127.0.0.1
after config:cache (built with DB_HOST=db), cron-like env:
db_host=NULL cfg_host=db
So a .env file baked into the image works (Laravel reads it itself), and so does running php artisan config:cache in the entrypoint, while the environment is still there. The common fix,
printenv > /etc/environment at startup, hands your values to PAM’s parser, which reads one
KEY=value per line with its own quoting rules, so a value with quotes or a newline arrives changed. If you
want the environment, use a scheduler that doesn’t wipe it (options 2 to 4).
The output goes nowhere. cron mails a job’s output, and a container has no mail. The usual fix is
>> /proc/1/fd/1 2>&1 on the crontab line, which writes to the stdout of PID 1, which is what docker logs reads. It only works if the job runs as the same user as PID 1.
docker stop kills the running task. Docker sends SIGTERM to PID 1 and waits 10 seconds. A
process that is PID 1 ignores any signal it has no handler for, so cron -f can sit through the 10
seconds and then everything in the container is SIGKILLed. With init: true, cron exits on the
SIGTERM, and the task still gets SIGKILLed when the container goes away. Either way, a task that was
halfway through a 20-minute import doesn’t finish, and if it used withoutOverlapping() it leaves a
lock behind (below).
cron also makes the container run two things if php-fpm is in the same image. Keep it to a scheduler container.
2. Supercronic running schedule:run
Supercronic is a crontab runner written for containers. It keeps the environment, prints each job’s output and exit code to stdout, and on SIGTERM waits for running jobs to finish. The crontab line is the same:
FROM php:8.5-cli
ADD --chmod=755 https://github.com/aptible/supercronic/releases/download/v0.2.51/supercronic-linux-amd64 /usr/local/bin/supercronic
COPY . /var/www
RUN echo '* * * * * cd /var/www && php artisan schedule:run' > /etc/laravel.crontab
CMD ["supercronic", "/etc/laravel.crontab"]
(No user column: Supercronic runs everything as the container’s user.) It fixes the environment and
the shutdown, on any Laravel version, which makes it the right pick for Laravel 12. What reaches
docker logs is schedule:run’s summary line per task, not the task’s own output, since Laravel
sends that to /dev/null before Supercronic ever sees it. And Supercronic logs schedule:run’s exit
code, which is 0 even when a task failed. RunWisp vs Supercronic compares the two
for plain crontabs.
3. schedule:work in its own container
schedule:work stays in the foreground and starts schedule:run at the top of every minute. Run it
as a second service from the app’s image:
services:
app:
image: my/laravel-app
# php-fpm, nginx or Octane
scheduler:
image: my/laravel-app
command: ["php", "/var/www/artisan", "schedule:work"]
init: true
stop_grace_period: 5m # longer than the slowest scheduled task
environment:
LOG_CHANNEL: stderr
# plus the app's env_file / environment
It gets the container’s environment, and its output is in docker logs scheduler. The disagreement
about production comes down to one change. Laravel 13.18 (June 2026) taught schedule:work to catch
SIGTERM: it stops starting new runs and waits for the ones in flight. We sent SIGTERM five seconds
into a 20-second task:
Laravel 13.38, schedule:work as PID 1
SIGTERM at 19:10:05
19:10:00 Running ['artisan' report:daily --sleep=20] ..... 20s DONE
19:10:20 Running ['artisan' report:daily --fail] ...... 105ms DONE
exited after 15.2s
It finished the running task and the one queued behind it, then exited 0. That took 15 seconds, so
with Docker’s default stop_grace_period of 10 seconds the task would have been SIGKILLed anyway.
Set it longer than your slowest task.
The same test on Laravel 12.70.1, which has no signal handler in schedule:work:
- As PID 1, it ignored SIGTERM and was still running 15 seconds later. Docker would SIGKILL it and the running task at 10.
- Under
init: true, it exited the instant the signal arrived, and leftschedule:runand the task running as orphans until the container went away and they were SIGKILLed with it.
Laravel catches the signal through the pcntl extension. The official php images don’t include
it, so add RUN docker-php-ext-install pcntl, or on 13.18+ SIGTERM falls through to the Laravel 12
behaviour. Server Side Up’s images include it.
4. Supervisor next to php-fpm
If the platform gives you one container per app, Supervisor can run php-fpm and the scheduler side by side:
[supervisord]
nodaemon=true
[program:php-fpm]
command=php-fpm -F
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
[program:scheduler]
command=php /var/www/artisan schedule:work
stopwaitsecs=300
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
redirect_stderr=true
Programs inherit Supervisor’s environment, so they see the container’s. Without the
stdout_logfile=/dev/stdout lines, the output goes to files under /var/log/supervisor instead of
docker logs. On docker stop, Supervisor sends SIGTERM to schedule:work alone and waits
stopwaitsecs (10 by default). On 13.18+ that’s a clean drain. On Laravel 12, schedule:work exits
at once and the task carries on until Supervisor exits and the container takes it down. Setting
stopasgroup=true doesn’t help there: the task gets SIGTERM and dies straight away instead.
It works, and it’s one more process manager and config format in the image. If you’re on 13.18+ and compose, option 3 is less to maintain. RunWisp vs supervisord covers Supervisor in more depth.
Problems you keep whichever you pick
schedule:run says DONE when a task fails
$ php artisan schedule:run
Running ['artisan' report:daily --fail] ........ 62.22ms DONE
⇂ '/usr/bin/php8.5' 'artisan' report:daily --fail > '/dev/null' 2>&1
$ echo $?
0
The command exited 1. On both Laravel 13.38 and 12.70.1, the summary line says DONE and
schedule:run exits 0, so cron, Supercronic, Supervisor and any heartbeat ping on schedule:run
see a success. Laravel does report an exception, “Scheduled command […] failed with exit code [1]”,
through your log channel. With LOG_CHANNEL=stderr that reaches docker logs; with the default it
goes to storage/logs/laravel.log inside the container.
To act on a failure from Laravel itself, use the hooks:
Schedule::command('reports:send')
->dailyAt('07:00')
->onFailure(fn () => Log::critical('reports:send failed'))
->pingOnFailure('https://hc-ping.com/your-uuid/fail')
->pingOnSuccess('https://hc-ping.com/your-uuid');
That has to be added to each task. Cron job failure alerts covers the heartbeat side.
The output goes to /dev/null
Every scheduled command’s output is redirected to /dev/null unless you say otherwise, under cron,
schedule:work and everything else. To get it into docker logs:
Schedule::command('reports:send')->dailyAt('07:00')->appendOutputTo('/proc/1/fd/1');
/proc/1/fd/1 is the stdout of the container’s PID 1. appendOutputTo('/dev/stdout') looks like it
should do the same and doesn’t: schedule:run captures that stream and throws it away. We tried
both under schedule:work; only /proc/1/fd/1 showed up. Its lines can land between the scheduler’s
summary lines, which is cosmetic. It works when the task runs as the same user as PID 1, or as root.
Tasks run one after another
Tasks due in the same minute run in order, each waiting for the one before. In the test above, a
20-second task pushed the next one 20 seconds late. A 40-minute import at 03:00 delays everything
else scheduled at 03:00 by 40 minutes. Add ->runInBackground() to slow commands so they don’t
hold up the rest.
A killed task leaves its lock for 24 hours
withoutOverlapping() takes a lock in the cache that expires after 1,440 minutes by default. On
SIGTERM, Laravel 13 with pcntl loaded releases it and exits; Laravel 12 has no release-on-signal, so a task stopped by
SIGTERM leaves the lock too. SIGKILL can’t be caught, and Docker sends SIGKILL to whatever is still running when the grace
period runs out. We SIGKILLed a running task and ran the scheduler
again with nothing running:
$ php artisan schedule:run
INFO No scheduled commands are ready to run.
The lock sat in the cache_locks table with an expiry 24 hours out, and the task was skipped every
minute without a log line. With CACHE_STORE=database or Redis, recreating the container doesn’t
clear it. php artisan schedule:clear-cache does, or pass a shorter expiry,
withoutOverlapping(30). Preventing overlapping runs has more on
locks that outlive their owner.
onOneServer needs a shared cache
onOneServer() takes an atomic lock in the default cache store, which must be database, redis,
memcached or dynamodb and shared by every container. Laravel 11+ defaults to
CACHE_STORE=database, so it works when all containers use one database. With the file store each
container has its own lock, and a scheduler in each of three web replicas runs each task three times.
Run one scheduler container, and keep onOneServer() for the minute during a deploy when the old and
new containers overlap.
Run the schedule under RunWisp
RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single
binary, with a web UI that records every run’s output, exit code and duration. Its PHP SDK,
runwisp/runwisp on Packagist, adds php artisan runwisp:work, which takes the place of schedule:work and reads the same routes/console.php.
composer require runwisp/runwisp
Composer asks to allow the package’s plugin, which downloads the runwisp binary into vendor/.
Saying yes adds it to allow-plugins in composer.json, so the composer install in your Dockerfile
fetches the binary too. It needs PHP 8.2 or newer and Laravel 11, 12 or 13.
The scheduler service from option 3 then runs runwisp:work:
services:
scheduler:
image: my/laravel-app
command: ["php", "/var/www/artisan", "runwisp:work"]
init: true
stop_grace_period: 5m # longer than the slowest scheduled task
environment:
RUNWISP_HOST: 0.0.0.0
RUNWISP_PASSWORD: ${RUNWISP_PASSWORD}
ports:
- "127.0.0.1:9477:9477"
volumes:
- runwisp:/var/www/storage/runwisp
# plus the app's env_file / environment
volumes:
runwisp:
Each scheduled event becomes a RunWisp task, and each run is php artisan runwisp:run <name> in a
new process with the container’s environment. Against schedule:run, that changes four of the
problems above:
- A failed command is a failed run. The command’s exit code is the run’s, and its output goes to
the run’s log instead of
/dev/null, withoutappendOutputTo. - Tasks don’t wait for each other. Runs of different tasks happen at the same time, so a
40-minute import doesn’t hold up the rest.
runInBackground()is ignored. - It drains on stop, on Laravel 11 and 12 too. With
pcntl,runwisp:workstops taking runs on SIGTERM and waits for the running ones, the wayschedule:workdoes on 13.18+. onOneServer()isn’t needed. Workers that sharestorage/runwispshare one RunWisp, and each run happens once.
The rest of the schedule works as before: Schedule::job() and Schedule::call() become tasks too,
and when(), skip(), environments(), evenInMaintenanceMode() and the onFailure() hooks apply
as they do under schedule:run. Give closures a name(), or the task is named after its file and
line, and moving the line starts a new run history.
Retries, timeouts and alerts go on the command class, with the keys named as in runwisp.toml:
use RunWisp\Task;
#[Task(retry_attempts: 3, timeout: '30m', notify: ['slack'])]
final class SendReports extends Command
{
protected $signature = 'reports:send';
// ...
}
The slack notifier goes in config/runwisp.php, which php artisan vendor:publish --tag=runwisp-config creates:
'config' => [
'notifiers' => [
'slack' => ['type' => 'slack', 'webhook_url' => env('SLACK_WEBHOOK_URL')],
],
],
php artisan runwisp:check lists every task with its schedule and next run, and exits 1 when RunWisp
would reject the config, such as for a misspelled key, so it can run in CI before you deploy.
The limits, honestly:
- The task output isn’t in
docker logs. It’s in the web UI and in the run logs understorage/runwisp.runwisp:workitself prints where to find the dashboard and little else. - The web UI needs setting up in a container. It listens on 127.0.0.1 unless you set
RUNWISP_HOST, and withoutRUNWISP_PASSWORDit picks a new password on every start. - A SIGKILLed task still leaves its lock.
withoutOverlapping()takes Laravel’s cache lock as before, so the 24-hour lock still applies when the grace period runs out. - Laravel 11 or newer. On Laravel 10, or if you can’t add a Composer package, see below.
Without the SDK
RunWisp can also run single artisan commands as plain tasks from a runwisp.toml, without touching
the app. Run it as its own compose service with the -docker image tag and the Docker socket, and
point each task at the app container. It execs the command there, with the app’s code and
environment:
[tasks.reports-send]
cron = "0 7 * * *"
compose_service = "app"
run = "timeout 1800 php artisan reports:send"
The timeout goes inside the command because Docker can’t cancel an exec. The Docker socket is root
on the host, so decide on that on purpose. Those schedules move out of PHP, so onOneServer(),
environments(), when() and the maintenance-mode skip don’t apply to them, and closures and jobs
stay with schedule:run. Docker tasks has the details.
Laravel’s scheduler is good, and RunWisp adds nothing to what it can schedule. What it adds is the part you can’t see in a container: each task’s runs, output, exit codes and a page when one fails.
Which one to use
- Laravel 13.18+, compose: a
schedule:workcontainer withinit: true,pcntland a longstop_grace_period. - Laravel 12 or older: Supercronic running
schedule:run. - One container for everything: Supervisor with php-fpm,
stdout_logfile=/dev/stdoutand a longstopwaitsecs. - Debian cron in the container: only with
config:cachein the entrypoint and/proc/1/fd/1on the crontab line, and you’ll still lose running tasks on every deploy. - Whatever you pick:
appendOutputTo('/proc/1/fd/1'),LOG_CHANNEL=stderr, andonFailureorpingOnFailureon anything you’d want to hear about. - You want each task’s history and an alert when it fails, without wiring hooks into every
task:
composer require runwisp/runwispand runphp artisan runwisp:workin place ofschedule:work. The Laravel guide has the details, and the code is on GitHub.
Common questions
Is php artisan schedule:work OK to use in production?
Why can't my Laravel cron job see the Docker environment variables?
Why is my scheduled command's output not in docker logs?
Does onOneServer work when the scheduler runs in Docker?
What happens to a running scheduled task when the container stops?
Can I get a run history and failure alerts for each Laravel scheduled task?
Read next
-
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.
-
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.