Replace Heroku Scheduler with cron jobs on your server
Follow this on your own server. Start free, no card needed
Heroku’s own documentation for Scheduler says the quiet part out loud: “Scheduler job execution is expected but not guaranteed.” The same page says that in very rare instances a job may be skipped or may run twice, and the only frequencies on offer are every 10 minutes, every hour and every day. For anything you’d notice missing, a nightly report, a subscription renewal check, a cleanup task that keeps a table from growing forever, that’s not a great foundation. Heroku’s own docs recommend a custom clock process instead for anything critical.
This guide replaces Scheduler with plain cron on a server you control: how to write the job, how to stop two runs from overlapping, how to get the timezone right, and where the logs actually go.
What you’ll need
- A server with your app already running on it, or a fresh one if you’re consolidating. A Hetzner CX23 (2 vCPU, 4 GB RAM, 40 GB NVMe, €5.99/month excl. VAT) is enough for a web app plus its scheduled jobs.
- SSH access with a user that can edit its own crontab, or root if the job needs to run as a service account.
- A list of what Scheduler is currently running. Open the Scheduler dashboard with
heroku addons:open scheduler -a your-app-nameand write down each job’s command, frequency and next run time before you start.
Step 1: Write the job as a plain command or script
Whatever Scheduler currently runs, whether that’s rails runner SomeJob.perform, python manage.py cleanup, or a raw SQL script, it needs to be something you can invoke as a single shell command without Heroku’s dyno environment doing anything implicit for you. Wrap it in a small script if it’s more than one line, so cron has one thing to call and you have one place to add logging:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/myapp
set -a
source /opt/myapp/.env
set +a
bundle exec rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1
Make it executable:
chmod +x /opt/myapp/bin/nightly-report.sh
set -euo pipefail matters more here than in most scripts: cron won’t retry a failed job for you, and without it a failing step partway through can leave the rest of the script quietly running anyway and reporting success. set -a before source exports every variable the file defines, so the command you run actually sees them; a plain source would only set them inside the script’s own shell.
Run it once by hand before you schedule it:
/opt/myapp/bin/nightly-report.sh
tail -n 20 /var/log/myapp/nightly-report.log
You should see the job’s normal output in the log, and echo $? right after the script should print 0. Create /var/log/myapp first (sudo mkdir -p /var/log/myapp && sudo chown $USER /var/log/myapp) if it doesn’t exist.
Step 2: Load environment variables explicitly
This is the step that catches almost everyone moving off Heroku. Scheduler jobs run inside a one-off dyno that inherits your full config var set automatically. Cron runs with a minimal environment, usually just PATH, HOME, SHELL and whatever the crontab itself sets, nothing from your app’s .env or your shell’s login profile.
Load your environment file explicitly inside the script, as shown above, or set variables at the top of the crontab itself, one NAME=value per line:
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
DATABASE_URL=postgres://user:pass@localhost:5432/myapp_production
Either works. The script approach is easier to keep in version control alongside your app; the crontab approach keeps secrets out of a file that gets deployed with your code. Pick one and don’t rely on cron picking up anything from a login shell, because it won’t.
Step 3: Set the timezone correctly
Heroku’s docs note that the next run time for daily Scheduler jobs is in UTC, and tell you to add the UTC offset yourself if you want a job at a certain local time. If you did that, your current schedule is already written in UTC. System cron, by contrast, runs in whatever timezone the server itself is set to, which is usually UTC on a fresh cloud server but worth checking.
Check what the server thinks the time is:
timedatectl
You should see a Time zone: line. If it says Etc/UTC (UTC, +0000), you can copy your Scheduler times across unchanged: a daily job at 03:00 on Heroku becomes 0 3 * * *.
If you’d rather write schedules in local time, change the server’s timezone and restart cron so it picks up the change:
sudo timedatectl set-timezone America/Toronto
sudo systemctl restart cron
Keep in mind that this changes the clock for everything on the server, including log timestamps, and that local time has daylight saving shifts: a job scheduled between 02:00 and 03:00 can be skipped or run twice on the nights the clocks change. Staying on UTC avoids both, which is why many teams keep their servers on UTC and convert in their heads.
Step 4: Add the crontab entry, with locking
Install the crontab for the user that should own the job:
crontab -e
*/10 * * * * /usr/bin/flock -n /tmp/myapp-sync.lock /opt/myapp/bin/sync-job.sh
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh
flock -n is the direct answer to Scheduler’s “runs can overlap” problem, except here you control it instead of hoping it doesn’t happen. It takes an exclusive lock on the given file before running the command, and -n means it gives up immediately rather than queuing a second run, if a previous run of the same job is still holding the lock. A ten-minute sync job that occasionally takes twelve minutes will simply skip that one overlapping run instead of stacking two copies of itself against the same database rows.
Confirm the crontab is installed:
crontab -l
You should see both lines listed back exactly as entered.
Step 5: Get logs somewhere you’ll actually look
Cron’s default behavior, mailing job output to the local user via sendmail, does nothing useful on a server with no mail transport agent configured, which is most of them. Without redirection, output simply disappears.
The script in Step 1 already redirects to a log file, which is the simplest fix. For anything you want aggregated instead of scattered across per-job log files, pipe through logger to send it to the system journal:
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /opt/myapp/bin/nightly-report.sh 2>&1 | logger -t myapp-nightly-report
Then tail it with:
journalctl -t myapp-nightly-report -n 50
You should see the job’s output timestamped at the schedule you expect, with nothing from other jobs mixed in.
Step 6: If your app runs in Docker Compose
If your app is deployed with Docker Compose rather than directly on the host, keep cron on the host and have each entry start a short-lived container from the app’s own service:
0 3 * * * /usr/bin/flock -n /tmp/myapp-nightly-report.lock /usr/bin/docker compose -f /opt/myapp/compose.yaml run --rm -T web bin/rails runner "NightlyReportJob.perform_now" >> /var/log/myapp/nightly-report.log 2>&1
docker compose run starts a fresh container from the web service’s image, with the same env_file and environment the service defines, runs the one command, and --rm removes the container afterward. -T turns off the pseudo-terminal, which cron doesn’t have. Because the job runs in the same image as the web process, it always uses the code from your latest deploy.
Check the path with which docker first; /usr/bin/docker is where Docker’s own packages install it on Ubuntu and Debian.
Running crond inside a container of its own is harder than it looks: the cron daemon starts jobs with a clean environment, so the variables Compose passes to the container don’t reach the jobs, and the crond binary and its crontab location differ between Alpine and Debian-based images. Host cron plus docker compose run avoids both problems.
The shortcut: do it with Railyard
Railyard’s Cron feature covers the script, the environment and the logs for you. Add a cron entry with a schedule expression like 0 3 * * * and a command, and Railyard runs it in a one-off container from your app’s own image, with the app’s environment variables already attached, so there’s no crontab to edit on the server and no .env to source. Schedules are evaluated in UTC, the same convention as Heroku Scheduler’s daily jobs, so times you wrote for Heroku carry over unchanged. Every run shows up in the run history with its exit code and output, so “did last night’s job actually run” is a page you check instead of a log file you have to remember to go look at.
One thing Railyard doesn’t do is skip a run while the previous one is still going. If a job can take longer than its interval, make it safe to run twice, or give it a longer interval.
It sits next to the Workers tab, which does the equivalent job for long-running background processes like Sidekiq or Celery rather than scheduled ones; see Workers and scheduled jobs. Both use the same image your web process runs, so there’s nothing extra to build or keep in sync.
Troubleshooting
The job runs at the wrong time. Almost always a timezone mismatch between what you assumed the server was set to and what timedatectl actually reports, especially right after moving from Heroku’s UTC-only Scheduler. Check the server’s timezone first, then either convert the schedule to the server’s timezone or change the server’s timezone as shown in Step 3.
The job seems to run twice, or interferes with itself. This is Scheduler’s “rarely, but it happens” overlap problem showing up on your own infrastructure instead, usually because a job occasionally takes longer than its interval. Wrap the command in flock -n as shown in Step 4; a skipped overlapping run is almost always safer than two copies running against the same data at once.
Nothing appears in any log, and you can’t tell if the job ran at all. Cron’s default mail-based output goes nowhere on a server without a configured MTA. Redirect output explicitly to a file or to logger, and verify your redirection actually fired by running the exact command cron would run, by hand, once.
The script works when you run it by hand, but fails under cron. This is almost always Step 2’s missing environment. Run it with a stripped-down environment to reproduce the problem locally: env -i /bin/bash -c '/opt/myapp/bin/nightly-report.sh' will fail the same way cron’s minimal environment does, which makes it much faster to find the missing variable than debugging on the server itself.
Permission denied running the script. Confirm it’s executable (chmod +x) and that the user whose crontab it’s in actually owns or can read every file and directory the script touches, including the log file’s parent directory if that directory doesn’t already exist.
FAQ
Can cron run jobs more often than Heroku Scheduler’s ten-minute minimum? Yes. Standard cron syntax only goes down to once a minute, but you can go further by having a once-a-minute entry launch a short script that loops with a sleep, if you genuinely need sub-minute scheduling, which is rare for anything that was previously running on Scheduler at all.
What’s the equivalent of heroku run for a one-off job? SSH in and run the command directly; there’s no dyno to spin up first. If the job needs the exact same environment as your running app, run it inside the app’s own container with docker compose exec web <command> rather than reaching for host-level tooling that might see a different environment.
Does cron survive a server reboot? Yes, cron or crond is a system service that starts at boot on every mainstream Linux distribution, and crontabs are stored on disk, not in memory. Confirm it’s enabled with systemctl is-enabled cron (or crond, depending on distribution) if you want to be certain after a fresh server setup.
How do I handle secrets a scheduled job needs, without putting them in the crontab file in plain text? Keep them in the .env file the script sources, with restrictive file permissions (chmod 600). Each user’s crontab is already private to that user, but keeping secrets in one .env file means the app and every scheduled job read the same values, and rotating a secret is one edit instead of several.
What happens if the server reboots mid-job? The same thing that happens if the process is killed any other way: whatever the job was doing stops, unfinished. Idempotent jobs (safe to run twice, or able to pick up where they left off) tolerate this fine; jobs that aren’t need to be made idempotent regardless of what’s scheduling them, since the same failure mode exists under Scheduler too.
Moving off Scheduler is mostly a matter of making explicit what Heroku was doing for you implicitly: the environment, the timezone, and what happens if two runs overlap. Once those three things are nailed down, cron is simpler to reason about than a hosted scheduler ever was, because you can read exactly what it will do from a text file instead of trusting a dashboard.
Why people pick Railyard for this
- Your own cloud account. Deploy to DigitalOcean, Hetzner, Vultr, AWS, OVHcloud, or any Linux server over SSH, billed directly by your provider with no markup.
- One flat price per team. Free is free forever: one server plus a database server, three apps, no card. Pro is $19 a month for up to five servers and unlimited apps. Team is $49 a month with unlimited servers. No per-app, per-deploy, per-seat or per-gigabyte charges.
- One-click templates. Deploy open-source apps, like databases, analytics and dev tools, to your own server in one click.
- Managed databases. Postgres, MySQL and Redis with scheduled, restore-verified backups and point-in-time recovery.
- Push-to-deploy. Deploy from GitHub, GitLab or the CLI, with automatic HTTPS and zero-downtime releases.