Skip to content

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 -e you 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. crontab does 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:

  1. What happens on the night the app in front of the job is being replaced, restarted, or is down?
  2. Which timezone is the expression read in — the container's, the host's, or someone else's?
  3. 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

LayerWho owns the clockWhose process runs the jobWhere the evidence livesTypical picks
Inside the containerthe platform, or the app's own scheduleryour application containerthe platform's run history, or the app's logsplatform scheduled tasks, framework schedulers (schedule:run), a workflow tool's schedule trigger
On the hostthe host's cron or systemda process on the server, outside your appwhatever you redirected, or journaldcrontab, a systemd timer
Outside your machinethe providera runner that is not your serverthe CI run log, or the provider's dashboardGitHub 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:

SchedulerShortest intervalTimezone the expression is read inSurvives a deploy?Runs while the app is down?Evidence after a failure
crontab1 minutethe host's timezoneyes — it lives on the host, not in the containeryes, it does not know the app existsonly what you redirected to a file
systemd timer1 minutethe host's timezoneyesyesjournald, plus catch-up for runs missed while the machine was off
Coolify scheduled task5-field cron, or a named schedulethe server running the resourcethe definition yes, the run no — a due run is skipped if the app is not runningnoexecution history: status, start and end time, duration, output, downloadable logs
GitHub Actions schedule5 minutesUTC by default; an IANA timezone can be setyes, it is a file in the repoyesthe run log; but the schedule is delayed under load, and queued jobs can be dropped
Vercel Crononce a minute on Pro; once a day on HobbyUTCyesthe endpoint has to answerfunction logs and invocations
n8n Schedule Triggerset per nodeunverified — I have not checked how an instance with several timezones resolves thisdepends on where the instance runsno — the job runs inside the n8n container, so a restart around that minute skips ita 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 crontab for the app's own maintenance. The job needs the app's environment and its current code. A host-level cron that calls into /opt/app is 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.target
sudo systemctl daemon-reload
sudo systemctl enable --now nightly-sync.timer
systemctl list-timers nightly-sync.timer
journalctl -u nightly-sync.service --since today

Persistent=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:

FieldWhat goes in it
Namenightly-sync
Commandpython /app/sync.py — a container path, using the binaries installed in the image
Frequency0 3 * * *, or one of the named schedules the docs list
Timeouthow long a single execution is allowed to run
Container namewhich 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.py

Add 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:

  1. 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.
  2. Reboot a machine inside a timer's scheduled window with Persistent=true on, and check what journalctl shows afterwards.
  3. Run a GitHub Actions schedule for a week on a real repository and record the actual delay on each run.
  4. Watch a failing crontab job 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.

Related Reading