Rails Scheduled Jobs: Solid Queue, Whenever, and GoodJob Cron
Contents
To run a recurring job in a Rails 8 app, add a task to config/recurring.yml with a job class and a schedule such as every day at 3am or 0 3 * * *, and keep Solid Queue's scheduler process running. Solid Queue has been the default Active Job backend since Rails 8.0, and its scheduler runs under bin/jobs or inside Puma through Solid Queue's plugin. Older apps may use the whenever gem, which writes crontab entries, and apps on GoodJob use its built-in cron. None of them alerts you when a run doesn't happen.
Behavior below is checked against the latest releases on October 25, 2026, mostly in their source: Rails 8.1.4, Solid Queue 1.7.0, whenever 1.1.4, GoodJob 4.22.1, Fugit 1.14.0, and sidekiq-cron 2.4.0.
Solid Queue, Whenever, and GoodJob Compared#
| Solid Queue recurring tasks | whenever | GoodJob cron | |
|---|---|---|---|
| Schedule lives in | config/recurring.yml |
config/schedule.rb, then crontab |
config.good_job.cron |
| Fired by | Solid Queue's scheduler process | The cron daemon on each server | Each GoodJob process with enable_cron |
| Zone if the schedule names none | config.time_zone |
The cron daemon's zone | TZ, else config.time_zone |
| Several servers | One enqueue per task and time | Runs on every server with the crontab | One enqueue per key and time |
| A run due while it was down | Skipped | Skipped | Skipped unless cron_graceful_restart_period covers it |
| Deploy step | None | whenever --update-crontab |
None |
On Sidekiq, sidekiq-cron parses schedules with Fugit and "doesn't schedule the same job multiple times when more than one Sidekiq process is running," and sidekiq-scheduler pushes cron jobs once across hosts but every and interval jobs once per host. Sidekiq monitoring covers both.
Solid Queue Recurring Tasks#
Define Tasks Per Environment in config/recurring.yml#
# config/recurring.yml
production:
nightly_report:
class: NightlyReportJob
queue: reports
schedule: every day at 3am
clear_solid_queue_finished_jobs:
command: 'SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)'
schedule: every hour at minute 12A task has a schedule and either a job class or a command, Ruby that Solid Queue evaluates inside SolidQueue::RecurringJob on the solid_queue_recurring queue; args, queue, priority, and description are optional (README).
Solid Queue reads the section named after the current Rails environment, and when there isn't one it reads the whole file, where production: is not a task (configuration.rb). So the file solid_queue:install generates schedules nothing in development or staging; give each environment that should run tasks its own section.
Fugit Parses the Schedule#
Solid Queue passes schedule to Fugit and accepts only a single cron (recurring_task.rb): five-field cron, six-field cron with seconds first, macros such as @daily, extensions such as L and #, or English such as every 5 minutes and every weekday at five pm. A phrase that yields two crons, like every day at 16:15 and 18:30, is rejected; make it two tasks.
An invalid task isn't skipped. The supervisor prints Invalid recurring tasks and exits before starting any process (supervisor.rb), and in the Puma plugin's default fork mode, Puma stops too (plugin). Run bin/jobs check in CI or before a deploy: it validates both files for the current environment without starting anything (README).
Time Zones#
Since Solid Queue 1.5, a schedule that names no zone is read in config.time_zone, which Rails defaults to "UTC"; config.solid_queue.time_zone overrides it (UPGRADING.md, engine.rb). A zone in the schedule wins: 0 9 * * * America/New_York and every day at 9am America/New_York follow New York's clock, daylight saving time included.
Without that default, as in GoodJob, sidekiq-cron, and older Solid Queue, Fugit's time library, et-orbi, resolves the local zone as the TZ environment variable, then Active Support's Time.zone, then the operating system's zone (zones.rb). A TZ set on a container moves every such schedule, so write the zone into each one, as in 0 3 * * * UTC.
Where the Scheduler Runs#
bin/jobs starts Solid Queue's supervisor, which runs the scheduler beside workers and dispatchers (README). A new Rails 8 app's config/puma.rb has plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"], and its Kamal config/deploy.yml sets SOLID_QUEUE_IN_PUMA: true, so production jobs run inside Puma (puma.rb, deploy.yml).
At boot, the scheduler sets an in-memory timer for each task's next occurrence and enqueues the job when it fires (recurring_schedule.rb). It never looks back, so a run due while it was down, during a deploy, a crash, or a refused boot, is skipped, and nothing records that it was due. SOLID_QUEUE_SKIP_RECURRING=true or bin/jobs --skip-recurring turns the scheduler off.
Several Servers Enqueue Each Run Once#
Solid Queue enqueues a task's job and inserts a row into solid_queue_recurring_executions in one transaction, and a unique index on task_key and run_at lets one scheduler win each occurrence (README, queue_schema.rb). That needs preserve_finished_jobs at its default of true, holds only while the jobs are kept (one day by default), covers only jobs on the Solid Queue adapter, and assumes every scheduler loads the same recurring.yml.
It guarantees one enqueue, not one execution: a retry or a manual re-run does the work again. Distributed cron covers locks and idempotency.
Whenever: Recurring Jobs as Crontab Entries#
# config/schedule.rb
set :output, "log/cron.log"
env :PATH, ENV["PATH"]
every 1.day, at: "3:00 am" do
runner "NightlyReportJob.perform_now"
endbundle exec whenever prints the crontab without writing it. This command, run from the app's directory, replaces the block marked nightly_app in the current user's crontab or appends one (command_line.rb):
bundle exec whenever --update-crontab nightly_app --set environment=productionFrom /srv/app, the job becomes this line in a login shell (setup.rb):
0 3 * * * /bin/bash -l -c 'cd /srv/app && bundle exec bin/rails runner -e production '\''NightlyReportJob.perform_now'\'' >> log/cron.log 2>&1'Where it goes wrong:
- The environment is fixed when the crontab is written. whenever 1.1 takes
RAILS_ENVfrom the shell that runs it, defaulting toproduction, and the jobcds into the directory it ran from (setup.rb).--set environment=...keeps a strayRAILS_ENV=developmentout. - A login shell is not your terminal's shell.
bash -lreads/etc/profileand the first of~/.bash_profile,~/.bash_login, and~/.profile, not~/.bashrc(Bash manual), so rbenv or asdf set up in~/.bashrcleaves the job withoutbundleor on a different Ruby.env :PATH, ENV["PATH"]writes the generating shell'sPATHabove the jobs (job_list.rb). Keep secrets out ofenvlines, which store them in the crontab; cron environment variables covers better ways. - Nothing changes until
--update-crontabruns. Capistrano 3's recipe runs it afterdeploy:updated, on:dbservers by default (whenever.rake); other deploys need it as a step. Without an identifier, the block is named after the absolute path ofschedule.rb(command_line.rb), so running it from a new release directory leaves the old block, stillcding into the old release, beside the new one. - Containers usually have no cron. The Dockerfile
rails newgenerates installs curl, the database client, libvips, and jemalloc, but no cron daemon (app_base.rb), and Kamal never runs whenever. - Times are in cron's zone.
3:00 ambecomes0 3, and the cron daemon applies its own zone, normally the system's, notconfig.time_zone. On cronie (Fedora, RHEL, Arch),env "CRON_TZ", "UTC"sets the zone for the entries below it (crontab(5)); Debian's and Ubuntu's cron "does not support per-user timezones" (Debian crontab(5)). - Missed runs stay missed. After a reboot, cronie and Debian's cron match from the current minute on (cronie cron.c, Debian cron.c).
On Heroku, Heroku Scheduler covers the add-on that runs a command in a one-off dyno, and when to use a clock process instead.
GoodJob Cron#
# config/application.rb
config.good_job.enable_cron = true
config.good_job.cron_graceful_restart_period = 5.minutes
config.good_job.cron = {
nightly_report: {
cron: "0 3 * * * UTC",
class: "NightlyReportJob",
description: "Nightly CSV report",
},
}Cron runs in every GoodJob process with enable_cron set, the CLI or :async mode in the web server, and it defaults to false (README). A unique index on cron_key and cron_at admits one job per key and time across processes, as long as GoodJob keeps cron-created job records (README). On start, cron_graceful_restart_period, off by default, re-enqueues runs that fell due within that window. GoodJob applies no default zone, so a zoneless schedule follows Fugit's local zone.
Why a Rails Recurring Job Doesn't Run#
Check, in order, that the job was enqueued, that a worker took it, and that it succeeded.
No Scheduler Is Running, or It Has No Tasks#
The deploy doesn't start bin/jobs, SOLID_QUEUE_IN_PUMA isn't set, SOLID_QUEUE_SKIP_RECURRING=true was copied from staging, the tasks sit under another environment's key, or an invalid task stopped the supervisor at boot. On GoodJob, enable_cron is off everywhere. In the default fork mode, each Solid Queue process sets its title (procline.rb): the scheduler's lists its task keys, and each worker's lists its queues.
ps -eo args | grep '[s]olid-queue'No solid-queue-scheduler line means nothing will be enqueued: Solid Queue starts a scheduler only when it finds tasks for the current environment and isn't told to skip them (configuration.rb).
The Job Was Enqueued, but No Worker Took It#
Each run the scheduler enqueues leaves a row with its run_at. Check a task's last few in bin/rails console:
SolidQueue::RecurringExecution.where(task_key: "nightly_report")
.order(run_at: :desc).limit(3)
.map { |execution| [execution.run_at, execution.job&.status] }No row for last night means the scheduler never enqueued it. :ready means the job is waiting for a worker: the task's queue:, or the job's queue_as, matches no worker's queues: in config/queue.yml (command tasks need solid_queue_recurring). :failed means the run raised an error nothing handled. A run that retry_on re-enqueued shows :finished, because each retry is a new job row with the same active_job_id. nil means the finished job was already cleared.
The Job Ran and Raised#
Solid Queue has no retries of its own (README). retry_on re-enqueues the job, by default about 3 seconds later for up to 5 attempts, then re-raises (exceptions.rb), and Solid Queue keeps the failed execution until someone retries or discards it. The next run starts on schedule regardless, so a job can fail nightly while its schedule looks fine.
For whenever, crontab -l as the jobs' user shows what is installed; check it against the pitfalls above.
Alert on Missed and Failed Runs With HTTP Check-Ins#
There is no CronRadar SDK for Ruby or Rails and no auto-discovery of recurring.yml, whenever, or GoodJob cron, so each job reports with its own HTTP check-in. In this dead man's switch pattern the monitor expects a check-in on the job's schedule, so a run that never happens raises an alert. This concern adds check-ins to any Active Job class, whichever scheduler enqueues it:
# app/jobs/concerns/cron_radar_monitored.rb
require "erb"
require "net/http"
# Reports a scheduled job's runs to CronRadar. Monitoring errors are logged, never raised.
module CronRadarMonitored
extend ActiveSupport::Concern
PING_URL = "https://cron.life/api/v1/ping"
TIMEOUT = 5 # seconds, for each of connect, write, and read
included do
class_attribute :cronradar_monitor, instance_writer: false
around_perform :report_run_to_cronradar
# Runs only when Active Job gives up: retry_on is out of attempts,
# discard_on matched, or nothing handled the error.
after_discard do |_job, error|
report_to_cronradar("fail", message: "Raised #{error.class.name}")
end
end
class_methods do
def monitor_with_cronradar(key, schedule:, grace_period: nil)
self.cronradar_monitor = { key: key, schedule: schedule, grace_period: grace_period }.freeze
end
end
private
def report_run_to_cronradar
report_to_cronradar("start")
yield
report_to_cronradar("complete")
end
def report_to_cronradar(event, message: nil)
monitor = cronradar_monitor
return unless monitor
api_key = ENV["CRONRADAR_API_KEY"]
if api_key.blank?
logger&.warn("CRONRADAR_API_KEY is not set; skipped #{event} for #{monitor[:key]}")
return
end
uri = URI("#{PING_URL}/#{ERB::Util.url_encode(monitor[:key])}/#{event}")
uri.query = URI.encode_www_form({
schedule: monitor[:schedule], # five-field cron, UTC
gracePeriod: monitor[:grace_period],
run_id: job_id, # unchanged when retry_on re-enqueues the job
attempt: executions - 1, # executions is 1 on the first attempt
message: message,
}.compact)
request = Net::HTTP::Post.new(uri)
request.basic_auth(api_key, "")
request.body = ""
options = { use_ssl: true, open_timeout: TIMEOUT, write_timeout: TIMEOUT, read_timeout: TIMEOUT }
response = Net::HTTP.start(uri.host, uri.port, **options) { |http| http.request(request) }
return if response.is_a?(Net::HTTPSuccess)
logger&.warn("CronRadar #{event} for #{monitor[:key]} rejected with HTTP #{response.code}")
rescue StandardError => e
logger&.warn("CronRadar #{event} for #{monitor[:key]} not sent: #{e.class}")
end
endInclude it in each recurring job, with the schedule in five-field UTC:
# app/jobs/nightly_report_job.rb
class NightlyReportJob < ApplicationJob
include CronRadarMonitored
# recurring.yml says "every day at 3am", read in UTC.
monitor_with_cronradar "nightly-report", schedule: "0 3 * * *", grace_period: 900
queue_as :reports
retry_on Net::ReadTimeout, wait: 5.minutes, attempts: 3
def perform
NightlyReport.generate!
end
endHow it behaves:
startandcompletewrapperform.run_idis the Active Job ID, which aretry_onretry keeps because it re-enqueues the same serialized job (core.rb).perform_nowincrementsexecutionsbefore callbacks run, soexecutions - 1sends 0 on the first attempt (execution.rb).failwaits for Active Job to give up.after_discard(Rails 7.1+) runs whenretry_onruns out of attempts,discard_onmatches, or nothing handles the error (exceptions.rb). An attempt that will be retried sends nothing; the retry sendsstartagain with the samerun_id. A run that recovers raises no alert; itsattemptabove 0 records that it needed help. Afailalerts immediately. To hear about every failed attempt instead, reportfailfrom arescuein the around callback and re-raise; cron job retries weighs the two.- The job's result doesn't change. The concern never rescues the job's errors and logs its own. Connect, write, and read each give up after five seconds, and the calls are synchronous, so a slow network adds that time to the run.
- The monitor heals itself. Every check-in sends
schedule, so the first run creates the monitor and a deleted one comes back.scheduleandgracePeriodapply only when a check-in creates the monitor; change them later in the dashboard. - Check-ins arrive when a worker starts the job, not at enqueue. A backed-up queue shows as a late run, so let the grace period cover normal queue wait. Missed runs are checked each minute: the monitor turns
warningwhen the expected time passes andcritical, with an incident, once it is past the grace period and at least two minutes late. - Every run of the class reports, including one enqueued by hand, so include the concern only in scheduled jobs. A
command:task runs asSolidQueue::RecurringJob; give a task you monitor its own job class.
A worker killed mid-job, say by the out-of-memory killer, runs no callbacks; Solid Queue marks the job failed (README), and CronRadar has a start with no finish. Every check-in moves the expected time to the job's next occurrence; if that time plus the grace period passes with a start as the last event, the monitor goes critical as stuck. The next run's start resets that window, so a killed or hung run is flagged only if the job then goes silent. Give the work its own deadlines, such as HTTP and database statement timeouts, so a hang becomes an error and then a fail.
Each run sends two requests, against a limit of 60 a minute and 1,000 an hour per API key; use one CronRadar application and key per environment, set as CRONRADAR_API_KEY from the deploy's secrets. A whenever entry that runs a rake task can use the shell wrapper from the dead man's switch post, and Sidekiq::Job classes the middleware in Sidekiq monitoring.
Convert the Schedule to Five-Field UTC#
CronRadar reads standard five-field cron in UTC without seconds, macros, ?, L, W, #, or 7 for Sunday, and when both day fields are restricted it expects a run only on dates matching both. Ask Fugit what a phrase means, and Solid Queue when each task runs next, in UTC:
bin/rails runner 'puts Fugit.parse("every day at 3am").to_cron_s'
bin/rails runner 'SolidQueue::RecurringTask.find_each { |t| puts "#{t.key}: next #{t.next_time.iso8601}" }'The first prints 0 3 * * *. The second lists the tasks the last scheduler to boot saved; compare each with CronRadar's next expected run. On GoodJob, GoodJob::CronEntry.all and each entry's next_at do the same.
| Rails schedule | CronRadar schedule |
|---|---|
every day at 3am, read in UTC |
0 3 * * * |
every hour at minute 12 |
12 * * * * |
every weekday at five pm, read in UTC |
0 17 * * 1-5 |
every day at 9am America/New_York |
0 13 * * *, with a grace period of at least 3,600 seconds plus the run time |
0 9 L * * |
Schedule 0 9 28-31 * * in both; the job returns early unless tomorrow is the 1st |
every 15s or */30 * * * * * |
None: five fields can't express more than one run a minute |
09:00 in New York is 13:00 UTC in summer and 14:00 in winter, so in winter the monitor shows warning for the hour before each run. If config.time_zone isn't UTC, zoneless schedules are in that zone; convert them like the New York row.
Frequently Asked Questions#
How do I schedule recurring jobs in Rails 8?#
Add a task to config/recurring.yml under each environment that should run it, with a job class and a Fugit schedule such as every day at 3am. Solid Queue's scheduler enqueues it, so bin/jobs, or Puma with SOLID_QUEUE_IN_PUMA set, must be running.
Is the whenever gem still needed with Solid Queue?#
Not for work that runs as an Active Job: recurring tasks need no crontab, deploy step, or cron environment, and enqueue once across servers. whenever still fits a shell command, or work that must run while Solid Queue itself is down.
Why isn't my Solid Queue recurring task running?#
Usually no scheduler is running, or it found no tasks for the current environment. If solid_queue_recurring_executions has a row for the run, it was enqueued, so check that a worker listens on its queue. Runs due while the scheduler was down are skipped.
Do recurring tasks run twice with multiple servers?#
Solid Queue, GoodJob, and sidekiq-cron enqueue each occurrence once however many schedulers run; whenever runs the job on every server whose crontab has it. Enqueuing once doesn't stop a retry from repeating the work, so make jobs safe to run twice.
How do I monitor Rails scheduled jobs?#
Have each job check in with a monitor outside the app that knows its schedule; the schedulers record failures, not runs that never happen. CronRadar has no Ruby SDK, so an Active Job concern sends start, complete, and fail over HTTP with the job's five-field UTC schedule.
Put this guide to work
Know when the next run is missing
Connect one scheduled job, confirm a real completion, then verify the alert destination. Keep your existing scheduler in charge of running the work.