How to run a cron job without a server
Follow this on your own server. Start free, no card needed
The job itself is small: back up a table, send a weekly digest, expire some rows. Standing up a whole server, or a whole app, to run one command on a timer feels disproportionate to the job, and it’s a fair instinct. This guide covers what you actually need for a script that has to run on a schedule rather than respond to requests: the plain manual version first (a cheap VM, crontab, and the part people skip, confirming it actually ran), then Railyard’s real Cron feature, described accurately rather than aspirationally.
What you’ll need
- The script itself, runnable as a single command (
node send-digest.js,python cleanup.py, a shell one-liner), with whatever environment variables it needs already identified. - For the manual path: SSH access to any small Linux VM. A Hetzner CX23 (2 vCPU, 4 GB RAM, €5.99/month excl. VAT) is more than enough for a script that runs for seconds or minutes on a schedule, not continuously.
The manual version: a VM, crontab, and proof it ran
Write the job as one command
Wrap anything more than a single line in a script, so cron has one thing to call:
#!/usr/bin/env bash
set -euo pipefail
cd /opt/myjob
set -a
source /opt/myjob/.env
set +a
node send-digest.js >> /var/log/myjob/digest.log 2>&1
set -euo pipefail matters here specifically because cron doesn’t retry a failed run for you; without it, a failing step partway through can leave the rest of the script running anyway and reporting success. set -a before source exports every variable the .env file defines, so the command on the last line actually inherits them; cron itself starts jobs with a near-empty environment, not your shell’s.
Make it executable and run it once by hand before scheduling it:
chmod +x /opt/myjob/run.sh
/opt/myjob/run.sh
echo $?
You should see 0.
Add the crontab entry, with locking
crontab -e
0 3 * * * /usr/bin/flock -n /tmp/myjob.lock /opt/myjob/run.sh
flock -n takes an exclusive lock before running the command and gives up immediately, rather than queuing a second run, if the previous run is still holding it. Without this, a job that occasionally runs long overlaps with its own next scheduled run, often against the same rows.
Confirm it actually ran, not just that it was scheduled
A crontab entry existing says nothing about whether the job succeeded last night. The honest version of “monitoring” here is a dead man’s switch: the job itself pings a URL on success, and something alerts you if that ping doesn’t arrive on schedule. Self-hosting that check is itself a small always-on service; see self-hosting Uptime Kuma or self-hosting Healthchecks for that piece specifically. The crontab entry becomes:
0 3 * * * /usr/bin/flock -n /tmp/myjob.lock /opt/myjob/run.sh && curl -fsS https://hc-ping.com/your-check-id
The && means the ping only fires if run.sh actually exited successfully; a silent failure now shows up as a missed check-in instead of nothing at all.
For the timezone and full logging detail this condensed version skips over, replacing Heroku Scheduler with cron covers the same ground in more depth, including why system cron’s default UTC-vs-local-time behavior catches almost everyone moving off a platform scheduler.
What Railyard’s Cron actually is
Railyard has a real scheduled-task feature, labeled Cron in the dashboard, and it’s worth describing precisely rather than vaguely. Add an entry with a name, a schedule, and a command:
- The schedule is a plain five-field cron expression (
0 3 * * *), parsed and evaluated the same way system cron reads one, in UTC. - The command runs inside a fresh instance of your app’s own image, the exact build your web and worker processes are already running, not a separate container you have to keep in sync by hand.
- Every run is recorded: start time, exit code, and output, kept as run history per job, so “did last night’s job actually run” is a page you check rather than a log file you have to remember exists.
That last point replaces the dead-man’s-switch workaround from the manual version above with something built in: the run history itself is the record of whether the job ran and what happened, without a second service pinging a third one to tell you.
One honest limitation, the same one you’d hit running this yourself: Railyard’s Cron doesn’t skip a run while a previous one of the same job is still going. The flock -n protection from the manual version is something you still have to build into the job itself if it can realistically overrun its own interval, either by making the command safe to run twice concurrently, or by having it check and exit early if it detects a previous run still in progress.
See Workers and scheduled jobs for how Cron sits next to the separate Workers tab, which handles long-running background processes (a queue consumer, for instance) rather than scheduled ones; both run the same image, so a deploy that fixes the app fixes its scheduled jobs at the same time, by construction.
Setting it up
Add the job from the app’s Cron tab: a name, the schedule expression, and the command, exactly as you’d type it if you SSHed into the app’s own container and ran it directly. Environment variables the job needs are the same ones already set under the app’s Variables tab, since the command runs in that same image and environment; there’s no separate .env file to source and no crontab to edit on a server, because there’s no separate server running the job at all. See variables and secrets.
Troubleshooting
The job runs at the wrong time. Railyard’s Cron evaluates schedules in UTC; if you wrote a schedule assuming local time, convert it to UTC rather than assuming the platform reads it any other way. This is the same conversion step that trips people up moving off a platform scheduler that also used UTC, like Heroku’s.
The job seems to overlap with itself. As noted above, this isn’t prevented automatically; add the same safeguard you’d add to a plain crontab entry, either idempotency in the command itself or an explicit lock check at the start of the script, if the job can realistically take longer than its own interval.
Nothing shows up confirming the job ran. Check the run history on the Cron tab directly rather than application logs first; a run that completed with a non-zero exit code still shows up there with its exit code and output, which is usually faster to read than reconstructing the same information from the Logs tab.
FAQ
Do I need a whole app just to run one scheduled script? No more than you’d need a whole server for the manual version. If the script is genuinely the entire thing you’re deploying, with no web process at all, that’s a normal, supported shape; a scheduled job and a worker-only app both just mean there’s no web process type, not that something’s missing.
What’s the minimum interval a schedule can run at? Standard five-field cron syntax goes down to once a minute (* * * * *), the same floor as system cron; nothing sub-minute is expressible in that format regardless of where it’s evaluated.
How is this different from a background worker? A worker is a process that starts once and keeps running indefinitely, consuming a queue or holding a connection open. A cron job starts, does one thing, and exits, on a schedule. They’re different process shapes for different jobs, covered by two separate, related features rather than one trying to do both.
What happens if the underlying server restarts mid-job? The same thing that happens to any process killed any other way: it stops, unfinished. A job that’s safe to run twice (idempotent) tolerates this without any special handling; one that isn’t needs to be made idempotent regardless of what’s scheduling it, since the same failure mode exists under plain cron on a VM too.
Can I trigger the job manually, not just on its schedule? Yes, a scheduled job supports running on demand from the same place you’d check its run history, independent of waiting for its next scheduled time, which is useful for testing a change before trusting it to the actual 3 a.m. slot.
Whether it’s a crontab line on a five-dollar VM or an entry in a dashboard, a cron job is the same three things underneath: a command, a schedule, and a way to know it actually ran. Getting the third one right is usually the part that was missing, not the first two.
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.