Automating Tasks with a Schedule: Where Should the Job Actually Run?
One cron expression works in four places. The layer you choose decides whether a deploy takes the job with it, whether it still runs while the app is down, and where you look when it stops. The same nightly job, written four ways.
The one-line idea: the cron expression is the only portable part. Where the scheduler actually lives — inside the container, on the host, or on someone else's machine — decides whether a deploy takes the job with it, whether it still runs while the app is down, and where you look for evidence when it stops.
Who it's for: indie developers and part-time operators whose script already
works and who are deciding where to hang it: the server's crontab or a systemd
timer, the platform's own scheduled tasks, or an external scheduler such as
GitHub Actions.
What you can do after reading: label every scheduled job you own with its layer, timezone, log destination, and who gets woken up — then write the same job four different ways.
Scheduling a task takes one line of cron. Choosing where that line lives takes an afternoon — and it quietly decides three things: whether your next deploy takes the job with it, whether the job still runs while the app is down, and where you go looking when it stops.
The expression is the only portable part. Whose clock it is read on, whose process it runs in, and whose log holds the evidence all come from the layer you drop it into.
The failure is rarely dramatic. Nothing crashes. A file just stops being updated, and the first signal is that a number somewhere is older than it should be.
Why the same expression behaves differently in three places
Put one job — say, a nightly sync at 03:00 — in three places and ask what happens the next time you deploy:
- Inside the container. The container is replaced by the new one during the deploy. Anything you installed into it is gone: a
crontab -eyou ran in a shell, a file you dropped in/etc/cron.d, a daemon you started. This is the most common version of "my scheduled job disappeared", and it has nothing to do with cron being wrong — it is the same rule that makes uploaded files vanish after a deploy unless they live in a mounted volume. - On the host.
crontabdoes not know your application exists. It fires at 03:00 whether the app is up, down, mid-deploy, or serving errors — which is exactly what you want for some jobs and exactly what you don't for others. - Outside your machine. A CI scheduler or a hosting provider's cron has never heard of your server. It fires on its own clock, in its own network, and it can only reach your code through something you deliberately exposed.
There is a subtle fourth case worth separating out, because it is where platform schedulers live. A scheduled task defined in a platform (Coolify, or your app's own scheduler) keeps its definition in the platform's database, so the definition survives your deploys. The execution still happens inside a container, so a run that comes due while the container is not there simply does not happen. Coolify's documentation is explicit about this: an execution is skipped when the task is disabled, when the application or service is not running, or when the connected server is not functional.
That is the whole argument of this article in one sentence: the task comes back after the deploy — with one execution quietly missing.
Before the comparison, three questions decide almost everything:
- What happens on the night the app in front of the job is being replaced, restarted, or is down?
- Which timezone is the expression read in — the container's, the host's, or someone else's?
- When the job fails, what do you read, and who finds out?
Three jobs, three different answers
The same three questions land differently depending on what the job actually does:
- The report that has to be there at 09:00. Someone else notices if it is missing, so punctuality is the requirement and silence is the failure. This job wants a layer that fails loudly.
- The hourly import. It needs the app's credentials, its network, and its current code version. Pinning that in a host-level cron means the job drifts away from the app every time you deploy. The real risk here is overlapping runs, not punctuality.
- The nightly backup. Punctuality barely matters; evidence matters enormously. You need to know whether last night's run happened, and whether the file it produced can actually be restored. This is the job where "the scheduler kept a record" is worth more than "the scheduler is simple".
The three layers, and what each one owns
| Layer | Who owns the clock | Whose process runs the job | Where the evidence lives | Typical picks |
|---|---|---|---|---|
| Inside the container | the platform, or the app's own scheduler | your application container | the platform's run history, or the app's logs | platform scheduled tasks, framework schedulers (schedule:run), a workflow tool's schedule trigger |
| On the host | the host's cron or systemd | a process on the server, outside your app | whatever you redirected, or journald | crontab, a systemd timer |
| Outside your machine | the provider | a runner that is not your server | the CI run log, or the provider's dashboard | GitHub Actions schedule, Vercel Cron, cloud schedulers |
Two consequences follow from the table, and they are the reason to draw it before you write any YAML:
Proximity to the app and independence from the app pull in opposite directions. The closer the job sits to your application, the more it knows about it — environment variables, installed dependencies, the exact code version — and the more it shares the application's fate. The further away it sits, the more independent it is, and the more you have to rebuild by hand: credentials, network access, and the code checkout.
The layer that runs the job is rarely the layer that tells you it failed. Host cron is silent by default; a container scheduler records runs but sits behind a dashboard you may not open; a CI scheduler will email you, which is why people put jobs there that have no business being there. This is the same control-plane boundary that applies to everything else you self-host: the component that stores the configuration and the component that serves the traffic have different failure modes, which is why one can be down while the other keeps working.
Six schedulers, compared on failure behaviour
Everything in this table comes from the official documentation of each tool, crawled on 2026-09-23:
| Scheduler | Shortest interval | Timezone the expression is read in | Survives a deploy? | Runs while the app is down? | Evidence after a failure |
|---|---|---|---|---|---|
crontab | 1 minute | the host's timezone | yes — it lives on the host, not in the container | yes, it does not know the app exists | only what you redirected to a file |
| systemd timer | 1 minute | the host's timezone | yes | yes | journald, plus catch-up for runs missed while the machine was off |
| Coolify scheduled task | 5-field cron, or a named schedule | the server running the resource | the definition yes, the run no — a due run is skipped if the app is not running | no | execution history: status, start and end time, duration, output, downloadable logs |
GitHub Actions schedule | 5 minutes | UTC by default; an IANA timezone can be set | yes, it is a file in the repo | yes | the run log; but the schedule is delayed under load, and queued jobs can be dropped |
| Vercel Cron | once a minute on Pro; once a day on Hobby | UTC | yes | the endpoint has to answer | function logs and invocations |
| n8n Schedule Trigger | set per node | unverified — I have not checked how an instance with several timezones resolves this | depends on where the instance runs | no — the job runs inside the n8n container, so a restart around that minute skips it | a per-execution list, with retries |
Three rows deserve a note, because they are the ones people get surprised by.
One caveat on the table: the n8n row is the only one I have not checked against that product's documentation. The other five come from each product's official docs, crawled on 2026-09-23.
Coolify's timezone comes from the server, not the container. The same 0 3 * * * means something different on a UTC server and on a server configured to Asia/Shanghai. If you have ever written a job and then moved the server, this is the line that moved with it.
GitHub Actions is not a clock you should trust for punctuality. The docs warn that the schedule event can be delayed during periods of high load — the start of every hour is explicitly called out — and that if the load is high enough, some queued jobs are dropped. A scheduled workflow also only runs on the default branch, and in a public repository it is automatically disabled after 60 days without repository activity. That last one is a silent failure with a very long fuse.
Vercel Cron is a function call, not a daemon. On Hobby it can only run once a day: an expression that would run more often fails at deployment time. On every plan, the cron invokes a function, so the usage and pricing of functions apply to each execution.
Why not just use one of them for everything
The reasons to exclude a layer are more useful than the reasons to pick one:
- Not GitHub Actions for a database backup. Runners are outside your network. To reach the database you would have to expose it or tunnel in, and then the credentials live in CI. Backups belong next to the data they copy.
- Not host
crontabfor the app's own maintenance. The job needs the app's environment and its current code. A host-level cron that calls into/opt/appis a second copy of the deployment, and it will drift the first time you change how the app is built or where it lives. - Not a container scheduler for the job that must run even when the app is down. By design, it targets a running container. If the requirement is "this runs even while the app is being replaced", the requirement has already chosen the host layer for you.
The same job, written four ways
One job: sync data at 03:00 and leave evidence behind. Here it is in each layer, ready to copy.
1. Host cron — one line, two details that keep it debuggable
# crontab -e
# In /etc/cron.d/ you would add a user field between the schedule and the command.
0 3 * * * /usr/bin/flock -n /var/lock/nightly-sync.lock /opt/app/venv/bin/python /opt/app/sync.py >> /var/log/nightly-sync.log 2>&1
Three things in that line are doing real work. The paths are absolute, because cron's environment is not your shell's. flock -n makes an overlapping run exit instead of starting a second copy. The redirect is the only evidence you will ever have — without it, a failed run leaves nothing behind at all. (The environment differences that make this line necessary, and how to debug them, are their own topic; this article only assumes you will hit them.)
2. systemd timer — two units, and one line that changes the failure mode
# /etc/systemd/system/nightly-sync.service
[Unit]
Description=Nightly data sync
[Service]
Type=oneshot
User=deploy
WorkingDirectory=/opt/app
ExecStart=/opt/app/venv/bin/python /opt/app/sync.py# /etc/systemd/system/nightly-sync.timer
[Unit]
Description=Run the nightly sync at 03:00
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=120
[Install]
WantedBy=timers.targetsudo systemctl daemon-reload
sudo systemctl enable --now nightly-sync.timer
systemctl list-timers nightly-sync.timer
journalctl -u nightly-sync.service --since todayPersistent=true is the line this whole layer exists for. Per the systemd manual, the time the service was last triggered is stored on disk, and when the timer is activated the service is triggered immediately if it would have been triggered while the timer was inactive — "useful to catch up on missed runs of the service when the system was powered down". It only applies to timers configured with OnCalendar=. Email and cron do not have that; if the machine was off at 03:00, that run is gone.
RandomizedDelaySec=120 keeps every timer on the machine from firing in the same second, which matters as soon as you have more than a couple of them.
3. A platform scheduled task — five fields in a panel, no file
In Coolify, a scheduled task is a command that runs inside an application or service container. There are five settings:
| Field | What goes in it |
|---|---|
| Name | nightly-sync |
| Command | python /app/sync.py — a container path, using the binaries installed in the image |
| Frequency | 0 3 * * *, or one of the named schedules the docs list |
| Timeout | how long a single execution is allowed to run |
| Container name | which container the command runs in, when the resource has more than one |
Two details change how you write that command. It runs inside the container, so it is container paths and container dependencies — not the host's. And the schedule is evaluated in the timezone of the server that runs the resource, so on a UTC server 0 3 * * * is not 03:00 where you live.
The payoff is the execution history: status, start and end time, duration, captured output, and downloadable logs, per run. That is the thing host cron cannot give you. The trade-off is in the same table as everything else — a run that comes due while the app is not running is skipped rather than queued.
4. GitHub Actions — a file in the repo, running on someone else's clock
name: nightly-sync
on:
schedule:
- cron: "0 3 * * *" # UTC unless a timezone is set
workflow_dispatch: # lets you run it now instead of waiting for 03:00
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python sync.pyAdd workflow_dispatch before anything else: without it, your only way to test a scheduled workflow is to wait for the schedule. Keep in mind that the file has to be on the default branch for the schedule to run at all, and that the time in the expression is UTC unless you specify otherwise.
Results: what I have verified, and what I still owe
I have not run this four-way comparison on my own machines yet, so this section is deliberately thin — and I am not going to fill it with numbers I do not have.
What is verified is behaviour, not my experience of it: Coolify skips rather than queues a run when the resource is down; GitHub delays and can drop scheduled runs under load; Vercel's Hobby plan refuses an expression that runs more than once a day; systemd's Persistent=true catches up after downtime, and cron does not.
What I still owe, each of which is one evening of work:
- Schedule a Coolify task for a minute when a deploy is running, then read the execution history. Confirm the skip and see how it is recorded.
- Reboot a machine inside a timer's scheduled window with
Persistent=trueon, and check whatjournalctlshows afterwards. - Run a GitHub Actions
schedulefor a week on a real repository and record the actual delay on each run. - Watch a failing
crontabjob with no redirect, and confirm that nothing, anywhere, records it.
Until those are done, treat this section as the checklist rather than the conclusion — which is also the honest state of the topic: most of what circulates about scheduled jobs online is documentation restated as experience.
Combining layers, and when not to
The interesting configurations are combinations, because running and reporting are different jobs:
- Trigger outside, execute inside. An external scheduler (or a webhook) calls an authenticated endpoint, and the work runs in the container where the data and credentials already are. This keeps the job's environment correct while making the trigger independent of a deploy — at the cost of an endpoint you now have to secure.
- Schedule in the platform, but report somewhere that will actually reach you. The layer that runs the job is rarely the layer that wakes you up. If the run has to be noticed, the notification path is a separate design decision from the schedule.
- n8n when retries and visibility matter more than exact timing. Every execution is recorded and retries are built in; the cost is another service you now run and back up.
- A dedicated scheduler service when you are past a couple of machines. Exactly-once semantics, retries, and central visibility stop being nice-to-haves once several servers are doing overlapping work. Below that, it is machinery you maintain for no benefit.
FAQ
Is a cron expression the same everywhere?
The five-field form is the common ground, and it is what Coolify and Vercel take. GitHub uses POSIX cron with an optional IANA timezone. Anything beyond that — six fields with seconds, @daily-style shortcuts, CRON_TZ — is implementation-specific. Check before you paste an expression from one tool into another.
Which timezone is my expression read in? Host layer: the host's timezone. Coolify: the server running the resource. GitHub and Vercel: UTC unless you say otherwise. When a job "moved by a few hours" on its own, a timezone change underneath it is the first thing to check.
Can a missed run happen later?
A systemd timer can, with Persistent=true, and only for OnCalendar= timers. Host cron cannot. Coolify's documented behaviour is to skip rather than queue. GitHub and Vercel do not catch up.
What if the job runs longer than the interval between runs?
Take a lock — flock -n in the cron example above — so a slow run and its successor do not overlap. Overlap is the failure that produces corrupted output rather than a missing one, and it is the one people diagnose last.
Does the job come back after a reboot? On the host, yes: the machine comes up, cron and the timer units are enabled and start. In a container, only if the container comes back — which is a property of your deployment, not of the schedule.
Do I need a second machine? Only when the scheduler has to outlive the machine it is on. A host-layer job cannot run on a server that is switched off; if that matters, the scheduler belongs outside, and the execution can still stay inside.
Where should the output go? Somewhere you will actually look. Host jobs: a file with the output redirected, plus journald if you use systemd. Container tasks: the platform's execution history. CI: the run log. In all three cases, decide separately who gets told when a run fails — a log nobody opens is not an alert.
Label each job you already have
Take the list of scheduled jobs you already have — most people have more than they can name — and write four lines for each one:
Layer: who fires it — container, host, or an external scheduler?
Timezone: which clock is the expression read on?
Evidence: where does the output go when it fails?
Notified: who finds out, and how?Then answer one question per job: if the app in front of it is being replaced at that exact minute, does the run still happen? If the answer is no, and you cannot live with that, move the job down a layer. The expression does not change — only what happens around it does.
This piece is the scheduling half of the pair: How to Go from Syntax to Shipping covers getting an app live for the first time, and the rest of the Automation hub picks up from there.