Cron for a Drupal site, managed in the admin UI and run by a small Go binary that sits beside the site rather than inside it.
The problem it solves is the one every hosted Drupal site has: crontab lives
somewhere a developer has to deploy to, the site has no idea what is in it, and
nobody can answer "what runs on this box, and did it run" without SSH. Druker
moves the schedule into Drupal, where it can be edited, reviewed and audited,
and leaves the running to a process that does nothing else.
It is not a replacement for hook_cron. Drupal's cron is one job among the
many this schedules, and drush cron is a perfectly good thing to put in it.
- Drupal 11
- Drush, on any server running the worker
- Go 1.23 or later, to build the worker. Only on the machine that builds it: the result is a static binary with no runtime dependencies
Install as you would any Drupal module. See Installing modules.
Then build and start the worker on each server, as described under Running the worker.
Everything lives under Configuration, in the Druker section, across three tabs: Dashboard, Cron Jobs and Servers.
- Add a Server for each machine that will run a worker, and give it the hostname that machine reports. A worker finds its own schedule by matching its hostname against these.
- Add Cron Jobs. A job is a Drush command plus when to run it. Leave the server empty to run a job on every server.
- Grant the Administer Druker jobs and servers permission to whoever should be editing the schedule. It is a restricted permission: a job is a command line that runs as the web user.
Jobs can be switched off without being deleted, from the Disable operation
on the job list. A disabled job keeps its command, schedule and server
assignment, and no worker is told about it until it is enabled again. For
differences between environments, see Several environments.
It answers the question the two lists cannot: what is each machine actually going to do. One card per server showing the jobs its worker will receive — its own, plus the ones that run everywhere — and a grid of the day, hour by hour, which is where you notice that everything you own runs at three in the morning.
It leads with what will not run, because that is the part nobody finds on their own: a schedule the worker cannot read, a shell job on a site that has not enabled them, a job assigned to a server that is disabled or gone. Those are left out of the day as well, so the picture and the warnings never disagree.
A disabled server still shows a card, because its worker does not stop — it stops being matched, and falls through to the jobs that belong to no server. The card says so, and says how many of its own jobs are now picked up by nobody.
Check the same things from a terminal with drush druker:check, which tests
every job the way the worker will and exits non-zero if any would be dropped.
It also warns about the two that are nobody's fault and easy to miss: a shell
job on a site that has not opted in, and a job left naming only servers that
are missing or disabled, which no worker anywhere is running.
Drupal owns the schedule. Two entity types: a Server, matched to a machine by hostname, and a Cron Job, which is a Drush command plus when to run it. A job with no server runs on every server.
The worker (worker/) is a single static binary. It asks Drupal what this
machine should be running, keeps those jobs on their schedules, and asks again
every refresh period — so a change made in the UI takes effect without a deploy
and without a restart.
The refresh is set per server and defaults to 30 minutes. A schedule is edited by a person, so it changes on the timescale people work at; half an hour is soon enough for an edit to land and rare enough that the asking costs nothing. One minute is the floor, enforced on both sides — the site clamps what it sends and the worker clamps what it is told, so neither a subscriber asking for two seconds nor a hand-edited config YAML can put a server into a loop that spends more time asking what to do than doing it. A payload with no refresh at all falls back to the 30-minute default rather than to zero.
They meet at one command:
drush druker:jobs <hostname>
which prints the schedule as JSON. That payload is the whole contract between
the two halves. It is produced by JobManager::formatJob(), consumed by the
Job struct in worker/schedule.go, and examples/sample-output.json is a
copy of it that the worker's tests parse on every run. If you change one
side, change the other, and update the sample — the two drifting apart is
silent, and it is the failure this module has already had once.
Build it into the project root — the directory holding vendor/ and
composer.json:
cd web/modules/contrib/druker/worker
go build -o /path/to/project/druker .
cd /path/to/project
./drukerGive -o an absolute path. A relative one is counted from worker/, and how
far up the project root sits depends on whether the site has a web/ docroot,
so the same ../../../.. lands somewhere different on two installs.
That is the one place it needs no arguments. Started from there it finds
vendor/bin/drush immediately, and everything below follows from it.
Where jobs run. Not where you started the worker — in the project root,
the directory above vendor/, worked out from the drush path. That is where
you stand when you run drush by hand, so a relative path in a command means
the same thing however the worker was launched. Override it with -dir if
your jobs expect somewhere else.
Finding drush. With no -drush it walks up from the working directory and
from the binary's own location looking for vendor/bin/drush. That covers
running it by hand; it does not cover systemd, which starts services in /.
Pass the path explicitly there — the worker says so plainly rather than
guessing:
level=ERROR msg="cannot find drush" error="no vendor/bin/drush above [/ /tmp]; pass -drush"
| Flag | What it does |
|---|---|
-dry-run |
Print what would run and exit. Exits non-zero if any job was unreadable |
-drush |
Path to drush. Required under systemd, optional otherwise |
-dir |
Directory jobs run in. The project root above vendor/ when empty |
-host |
Fetch another server's schedule, for checking what it would do |
-state |
Where completed one-time jobs are remembered |
-job-timeout |
Abandon a job that runs longer than this |
-verbose |
Log every minute it evaluates, not only what it does |
-log-format |
text (default) or json |
Logs go to stdout, one line per event, which is what a container platform
wants. The default is log/slog text — logfmt, for someone reading
journalctl:
time=2026-09-14T10:45:14.214+02:00 level=INFO msg="schedule loaded" server=web13 jobs=7 next_check_in=5m0s
-log-format=json emits the same events and the same keys as one JSON object
per line, for a shipper that would otherwise have to parse them back out:
{"time":"2026-09-14T10:45:14.214+02:00","level":"INFO","msg":"schedule loaded","server":"web13","jobs":7,"next_check_in":"5m0s"}The keys do not change between the two, so a query written against one format
still finds things in the other. Every line carries server, and job lines
carry job, so one collector can hold several servers and still separate
them. Send SIGTERM and the worker stops scheduling and gives running jobs 30
seconds to finish.
# /etc/systemd/system/druker.service
[Unit]
Description=Druker worker
After=network.target mysql.service
[Service]
Type=simple
User=www-data
Group=www-data
# Both given explicitly. systemd starts services in /, so nothing is found
# by walking up, and a job's relative paths would otherwise resolve there.
WorkingDirectory=/var/www/example
ExecStart=/var/www/example/druker \
-drush /var/www/example/vendor/bin/drush \
-dir /var/www/example \
-state /var/lib/druker/state.json
Restart=always
RestartSec=10
# One-time completions live here, and must outlive a deploy.
StateDirectory=druker
# stdout is already one structured line per event.
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now druker
journalctl -u druker -fRun it as the same user as the site. A job runs as whoever the worker is, and files it creates are owned by them.
A job is one of three kinds, and the form asks which before it asks anything
else — Recurring, Interval or One-time. Only the fields belonging
to the kind you pick are shown, and only those are saved: the other two are
cleared on the way in, so switching a job from one kind to another leaves
nothing of the old schedule behind it. The same three names are what the
payload calls them, so a job reads the same way in the UI, in
drush druker:jobs and in the worker's log.
Recurring jobs take standard five-field cron, an @-shortcut (@daily),
or plain English (every 5 minutes). Whatever you type is resolved to cron by
JobManager::resolveCronExpression() before it reaches the worker.
Both halves validate the result, with the same rules, on purpose: the form
refuses an expression the worker could not read, because a job the worker drops
is one that never runs and never says why. drush druker:check runs the same
test over everything at once.
Interval jobs repeat every n seconds, and exist for the periods cron
cannot say: every 25 seconds, every 75, every 90. Queue processors are what
they are mostly for — * * * * * is the finest cron can manage, and a queue
that matters is usually worth draining more often than once a minute. Pick
Interval on the job form, or add one from CollectJobsEvent when the
period itself has to be computed.
Boundaries are anchored to the epoch rather than to when the worker started,
so a job set to every 30 seconds runs on :00 and :30 of every minute on every
server, whatever time each of them booted, and a restart does not move it. An
offset shifts those boundaries, which is how the same queue processor on
three servers is kept out of the same second. A tick missed under load delays
a run rather than losing it. The floor is 5 seconds: the tick is one, but
every run is a process and a Drupal bootstrap, and a job that outlives its own
period is skipped rather than queued.
One-time jobs take a date instead, and are remembered once run so a restart
does not run them again. That memory is a small JSON file — if the worker runs
in a container, point -state at a volume, or a one-time job will run again
after every deploy. Completion is recorded when the job finishes, so a worker
killed mid-job will re-run it: at least once, rather than at most once.
Dependencies. A job can be set to run after another. It waits while that job is running and starts when it finishes. A dependency chain that loops back on itself is broken at one link, with a warning, rather than being left to deadlock both jobs.
Asynchronous jobs start alongside anything else due in the same minute. A job that is not asynchronous holds the rest of that minute behind it.
Drush or a shell. Every job picks a runner. Drush is the default, and it is
what most jobs want — including PHP, since php:script path/to/file.php is a
Drush command like any other. The shell runner hands the whole command line to
sh -c, so pipes, redirection and any binary on the machine work:
tar -czf /backups/site-$(date +%F).tgz web/sites/default/files
Shell jobs are off unless the site opts in, in settings.php:
$settings['druker_allow_shell'] = TRUE;Deliberately a setting rather than a permission. Without it, Administer Druker jobs and servers means "run any Drush command as the web user" — serious, but bounded. With shell jobs it means "run anything on this machine", and whoever granted the permission under the first meaning never agreed to the second. A setting puts that decision with the person who has filesystem access.
Until then the option is visible but disabled, with the line to add, and a
shell job that exists anyway is simply left out of the payload — so no worker
ever sees it. drush druker:check says which jobs are being withheld and why,
because a job that silently does not run is the worst way to find out.
No job ever overlaps itself. A job still running when its next turn comes round is skipped for that turn, with a warning in the log.
The failure this prevents is rarely the one people expect. It is a job that takes seven minutes on a five-minute schedule, quietly accumulating another copy every five minutes until the machine runs out of memory or database connections — silent until it is not.
After that it is the jobs that are not queue processors. A command that emails everyone who abandoned a cart in the last hour has no idea another copy of itself is making the same selection, and the customer gets the mail twice. The same goes for an import, a report, or anything that tags the rows it finds.
Queue processors are the safest case rather than the worst. Advanced Queue and core's queue both lease the items they hand out, so two processors normally take different work — which is why running one queue on several servers is throughput and not duplication. What overlap costs you there is narrower: a lease expiring while the first copy is still mid-item, after which a second can claim it and do it again.
The guard is per job, per server. The same job assigned to three servers still runs on all three at once; that is what assigning it to three is for.
Servers are config and jobs are content, and that split is what makes one schedule work across production, staging and a laptop.
A Server is a config entity, so it is exported to config/sync and
deployed everywhere — and, being config, its hostname and status can be
overridden per environment in settings.php. A Cron Job is a content
entity, living in each environment's database. A job assignment made by hand
on a laptop is gone the next time that database is replaced by a copy of
production, which is why per-environment differences belong on the servers
rather than on the jobs.
The usual shape: production splits the work across three machines, and every
other environment runs the lot on one. Add a fourth server for that one, and
have each job name its production worker and the fallback — the Servers
field takes as many as you like:
| Job | Servers |
|---|---|
| Live bid queue | worker_1, fallback |
| Pre-auction lots | worker_2, fallback |
| Mail and marketing | worker_3, fallback |
| Drupal cron | none — runs on every server |
Then let each environment say which of the two is real. On production,
nothing: the three workers are enabled as stored, and fallback is switched
off.
// settings.php, production.
$config['druker.server.fallback']['status'] = FALSE;Everywhere else, the opposite:
// settings.local.php, or the staging environment's settings.
$config['druker.server.worker_1']['status'] = FALSE;
$config['druker.server.worker_2']['status'] = FALSE;
$config['druker.server.worker_3']['status'] = FALSE;Exactly one of the two servers a job names is live in any given environment, so production splits the work three ways and everywhere else takes all of it:
production prod-1 Drupal cron, Live bid queue
prod-2 Drupal cron, Pre-auction lots
prod-3 Drupal cron, Mail and marketing
everywhere else local-box Drupal cron, Live bid queue,
Pre-auction lots, Mail and marketing
Give fallback a hostname the local machine actually reports. In a container
that is usually the container's own name rather than anything memorable, and
the worker uses os.Hostname() — drush druker:jobs with no argument prints
what it believes this machine is called.
To have a staging box behave as one particular production worker, override that worker's hostname instead of its status:
$config['druker.server.worker_2']['hostname'] = 'staging-box';That machine now gets exactly worker_2's schedule. Only one server can be
mapped this way per environment — a hostname resolves to a single server — so
for all three at once, either use the fallback above, or run three workers on
the one box with -host:
./druker -host worker-1 -state /var/lib/druker/worker-1.json &
./druker -host worker-2 -state /var/lib/druker/worker-2.json &
./druker -host worker-3 -state /var/lib/druker/worker-3.json &Give each its own -state. One-time completions are recorded by job id, and a
shared file would let one worker mark another's job as already done.
Disabling a server does not move its jobs anywhere. It stops that server
being matched by hostname, so its machine falls through to the jobs that name
no server at all. A job left naming only disabled or missing servers runs
nowhere — the dashboard says so, per job and on the server's card, and
drush druker:check warns about it wherever you run it.
Disabling a job switches it off everywhere, unconditionally. That is the
Enable/Disable operation on the job list, and it is what to reach for
instead of deleting something you may want back: the command, the schedule and
the assignment are all kept, and no worker is told about the job until it is
switched on again.
Job status is content, not config, so it cannot be overridden per environment and does not survive a database copy. Environment differences go on servers.
Never save an overridden entity from the UI in the environment that
overrides it. Editing and saving worker_2 on the box where settings.php
has rewritten its hostname writes that hostname into the stored config, and
the next drush cex carries a local value into production. The job list's
Enable/Disable operation is deliberately not offered for servers for this
reason; changing a server's flag for real is a trip to its form, on an
environment that does not override it.
Not every job is worth storing. Subscribe to CollectJobsEvent to add jobs
that are computed:
public function onCollectJobs(CollectJobsEvent $event): void {
$event->addJob([
'id' => 900,
'name' => 'Rebuild the index',
'command' => 'search-api:index',
'runner' => 'drush',
'type' => 'cron',
'cron' => '0 4 * * *',
'async' => TRUE,
'depends_on' => NULL,
]);
}An interval job is the same shape with every in place of cron, and an
optional offset:
$event->addJob([
'id' => 901,
'name' => 'Process the bid queue',
'command' => 'advancedqueue:queue:process bid_queue',
'runner' => 'drush',
'type' => 'interval',
'every' => 25,
'offset' => 4,
'async' => TRUE,
'depends_on' => NULL,
]);The shape is the same one formatJob() produces. Anything the worker cannot
read it drops, with a line in its log saying which job and why.
| Command | What it is for |
|---|---|
drush druker:jobs [host] |
The payload the worker fetches. --pretty to read it |
drush druker:check [host] |
Find jobs the worker would silently drop. Exits non-zero if any |
cd worker
go test ./... # includes parsing examples/sample-output.json
go test -race ./... # the runner is concurrent; run this before believing it
gofmt -l .No dependencies beyond the standard library, deliberately: the binary ships to machines that may have no route to a module proxy, and the cron parser it needs is smaller than the trust a dependency tree would ask for.
Klaxon is the other half of this. Druker runs your jobs; Klaxon is what tells you when one of them stopped — "cron is falling behind", "queue backlog", or any threshold you care to set — and says so in Slack, Telegram, Discord, email or a webhook.
It works in the other direction too. Klaxon's own scheduled alerts normally
ride on Drupal cron, so a wedged cron run silences them. Scheduling
klaxon:due and klaxon:deliver as Druker jobs puts the alerting on a process
that is not the one it is watching.
