← all posts

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.

0exit code from schedule:run after a scheduled command failed
24ha withoutOverlapping lock outlives a SIGKILLed task
10sdocker stop waits before SIGKILL; our drain took 15

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 envTask output in docker logsRunning task finishes on stopRunner sees a failed taskSchedule 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 left schedule:run and 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, without appendOutputTo.
  • 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:work stops taking runs on SIGTERM and waits for the running ones, the way schedule:work does on 13.18+.
  • onOneServer() isn’t needed. Workers that share storage/runwisp share 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 under storage/runwisp. runwisp:work itself 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 without RUNWISP_PASSWORD it 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:work container with init: true, pcntl and a long stop_grace_period.
  • Laravel 12 or older: Supercronic running schedule:run.
  • One container for everything: Supervisor with php-fpm, stdout_logfile=/dev/stdout and a long stopwaitsecs.
  • Debian cron in the container: only with config:cache in the entrypoint and /proc/1/fd/1 on the crontab line, and you’ll still lose running tasks on every deploy.
  • Whatever you pick: appendOutputTo('/proc/1/fd/1'), LOG_CHANNEL=stderr, and onFailure or pingOnFailure on 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/runwisp and run php artisan runwisp:work in place of schedule: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?
On Laravel 13.18 or newer, in its own container, yes. The Laravel docs only mention it for running the scheduler locally, but it's a plain foreground process that starts schedule:run every minute. Since 13.18 it catches SIGTERM, stops starting new runs and waits for the running ones, so docker stop doesn't cut a task off as long as stop_grace_period is long enough and the image has the pcntl extension. On Laravel 12 and older it has no signal handler: as PID 1 it ignores SIGTERM until Docker kills it 10 seconds later, and under an init it exits at once and the running task is killed when the container goes away.
Why can't my Laravel cron job see the Docker environment variables?
Debian's cron starts every job with an almost empty environment, so DB_HOST, APP_KEY and anything else you set with environment: in compose are missing, and Laravel falls back to the defaults in config/database.php, such as 127.0.0.1. Variables in a .env file inside the image still work, because Laravel reads that file itself. Running php artisan config:cache when the container starts also works, since the cached config holds the values. The cleaner fix is a scheduler that doesn't wipe the environment: Supercronic, schedule:work or Supervisor.
Why is my scheduled command's output not in docker logs?
Laravel sends every scheduled command's output to /dev/null unless you call sendOutputTo or appendOutputTo, whichever way you run the scheduler. schedule:run and schedule:work only print a summary line per task. To get the output into docker logs, call ->appendOutputTo('/proc/1/fd/1'), which writes to the stdout of the container's PID 1. ->appendOutputTo('/dev/stdout') doesn't work, because schedule:run captures and discards it. Set LOG_CHANNEL=stderr as well, so the exception Laravel reports for a failed task reaches the logs too.
Does onOneServer work when the scheduler runs in Docker?
Only if every scheduler container uses the same cache, and that cache is database, redis, memcached or dynamodb. Laravel 11 and newer default to CACHE_STORE=database, which works when all containers share one database. With the file or array store each container has its own lock, so a task marked onOneServer runs once per container. The simpler fix is to run exactly one scheduler container and keep onOneServer for the times a deploy briefly runs two.
What happens to a running scheduled task when the container stops?
It depends on what runs the scheduler and on the Laravel version. Debian cron, and schedule:work on Laravel 12, let it be SIGKILLed when the container stops. Supercronic waits for its jobs, and so does schedule:work on Laravel 13.18 or newer, up to Docker's stop_grace_period, which is 10 seconds by default. A SIGKILLed task that used withoutOverlapping leaves its lock in the cache for 24 hours, and the scheduler skips the task without a word until it expires or you run php artisan schedule:clear-cache.
Can I get a run history and failure alerts for each Laravel scheduled task?
Yes, with RunWisp, an open-source cron replacement that ships as a single binary. Its PHP SDK (composer require runwisp/runwisp) adds php artisan runwisp:work, which replaces schedule:work and reads the schedule in routes/console.php. Each command, job or closure becomes a task with its own run history: exit code, output and duration, plus retries, timeouts and a failure alert on Slack, Discord, Telegram, email or a webhook. A failed command counts as a failed run, tasks don't wait for each other, and on SIGTERM it waits for running tasks on Laravel 11 and 12 as well. The limits: it needs Laravel 11 or newer and PHP 8.2, task output shows in RunWisp's web UI rather than docker logs, the web UI needs RUNWISP_HOST and RUNWISP_PASSWORD set to be usable from outside the container, and a task SIGKILLed under withoutOverlapping still leaves Laravel's cache lock behind.
  1. 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.

  2. 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.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh