Docs/ Operations/ Cron Jobs

Cron Jobs

Run a command or an HTTP request on a schedule. Read “Where the command runs” before your first job: a cron job does not run inside your app's live container, but in a throwaway one that receives the same variables.

Creating a Cron Job

Cron jobs are managed from the Cron Jobs page of the dashboard, or with sh0 cron. A job is usually attached to an application.

  1. Open Cron Jobs in the sidebar and click Create Cron Job.
  2. Name -- a label, for example nightly-report.
  3. Application -- the app the job belongs to. Without an application, only HTTP commands are accepted (see below).
  4. Schedule -- a 5-field cron expression, evaluated in UTC.
  5. Command -- an HTTP request (curl, wget or a bare URL), or a program to run in a container.
  6. Optionally, a Container Image (empty = the application's image) and a Timeout (seconds), 300 by default. Then create the job.

The same from the CLI, on a server where these commands were run:

Terminal
$ sh0 cron create --app my-api --name nightly-report \
    --schedule "0 3 * * *" \
    --command "curl -s -X POST https://api.example.com/internal/report"
Cron job 'nightly-report' created (id: 72173a56-1962-47dc-88e5-2a276ef3bfcc)

$ sh0 cron trigger 72173a56-1962-47dc-88e5-2a276ef3bfcc
Cron job 'nightly-report' triggered.

$ sh0 cron runs 72173a56-1962-47dc-88e5-2a276ef3bfcc
RUN ID                                STATUS      EXIT    DURATION    STARTED
----------------------------------------------------------------------------------------------------
9e329bc7-01c5-41ec-9eb5-5ab745fde533  success     0       295ms       2026-10-02 02:33:23

Where the command runs

sh0 picks the execution mode from the command and the job's settings, in this order:

  • HTTP request -- a command starting with curl or wget, or a bare URL, is not executed by a shell: sh0 sends the request itself, from the server, with a 30-second limit. -X, -H and -d are honoured. A response status of 400 or more marks the run as failed. For a job attached to an app, an internal name of its project (my-api.internal, or the bare my-api) is resolved to that service.
  • Application image -- any other command runs in a new, throwaway container started from the application's current image, as a non-root user, on the sh0 network and on its project's network. It is not your running container: files written by the app at runtime are not there, and the container is removed after the run.
  • Explicit image -- if Container Image is set, the throwaway container is started from that image instead.
  • No application and no image -- a non-HTTP command is refused at run time. sh0 never runs a cron command on the host.
Note
Since 1.10.3: The container receives your app's variables. A job attached to an app gets the app's runtime environment variables and its project's, with the app's value winning, exactly as the app's own containers do. Build-time variables are not passed. A job's output is kept in its run history: a command that prints a secret leaves it there, readable by anyone with developer access to the app.

Measured on a Node.js app that has GREETING set on the app and REGION on its project: the job env prints both, next to the variables baked into the image.

env -- app image (Node.js 22)
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=0.0.0.0
REGION=eu431
GREETING=hello431
NODE_VERSION=22.23.3
YARN_VERSION=1.22.22
PORT=3000
HOME=/home/node

GREETING and REGION are both there. The HOME=/home/node line shows the non-root user.

Command rules

A command is a single program with its arguments. sh0 checks it when the job is created:

  • No shell. The characters ; | & > < and backticks, $( and line breaks are refused. Pipelines, && chains and variable expansion are not available.
  • Quotes only count for HTTP requests. In a container, the command is split on spaces: sh -c "echo hi" does not do what a shell would. For HTTP requests, quotes are honoured, so -H "Authorization: Bearer …" works.
  • Redirections are stripped rather than refused: > /dev/null, 2>/dev/null, &> /dev/null and 2>&1 are removed before the check, so a line pasted from a crontab is accepted.
  • 4,096 characters at most.
Terminal
$ sh0 cron create --app my-api --name cleanup --schedule "0 3 * * *" --command "env | grep X"
Error: Invalid command: Command contains forbidden character '|'. Use simple commands without shell operators. (VALIDATION_ERROR)

Tasks that need the database or secrets

A job attached to an app sees the app's variables and resolves its project's internal names, so a command such as python manage.py cleanup reads the same DATABASE_URL and reaches the same db.internal. When the work belongs inside your live app, two other options remain:

  • An HTTP endpoint. Expose the task as a route of your app, protected by a token, and let the cron job call it by its internal name. The work runs inside your live app, with its database connection and its secrets.
  • Or schedule inside the app, with your framework's scheduler (for example Celery beat, node-cron or Laravel's scheduler) when the task must not be reachable over HTTP at all.
Command
curl -s -X POST -H "Authorization: Bearer <token>" http://my-api.internal:3000/internal/cleanup
Note
sh0 sends the request from the server and resolves my-api.internal (or my-api) against the running services of the job's project only. A name that matches no running service fails with an explicit message; an app outside any project, or running on a remote node, must be called by its public URL. The token sits in the job's command in clear, visible to anyone who can read the job: give it no other use.

Cron Expression Syntax

sh0 uses standard 5-field cron expressions. Each field represents a time unit:

Cron expression format
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday = 0)
│ │ │ │ │
* * * * *
SymbolMeaningExample
*Every value* * * * * = every minute
,List of values0 8,12,18 * * * = 8am, noon, 6pm
-Range0 9-17 * * * = hourly from 9am to 5pm
/Step*/15 * * * * = every 15 minutes

Common Patterns

Frequently used cron schedules
# Every minute
* * * * *

# Every 5 minutes
*/5 * * * *

# Every hour at minute 0
0 * * * *

# Every day at midnight
0 0 * * *

# Every day at 3:30 AM
30 3 * * *

# Every Monday at 9:00 AM
0 9 * * 1

# First day of every month at midnight
0 0 1 * *

# Every weekday (Mon-Fri) at 6:00 AM
0 6 * * 1-5
Note
Schedules are evaluated in UTC, whatever the server's time zone. 0 3 * * * runs at 03:00 UTC. The scheduler checks for due jobs every 60 seconds, so a run can start up to a minute after its time.

Execution History & Logs

Each run is recorded with its status (success, failed or timeout), exit code, duration, start and end times, and output. In the dashboard, open Execution History on a job, then View output on a run. From the CLI: sh0 cron runs <id>.

Note
sh0 keeps the last 100 runs of each job and deletes older ones. Standard output and error are each kept up to 64 KB.

Enabling & Disabling Jobs

  • Disable stops scheduled runs without deleting the job or its history; Enable resumes them.
  • Trigger Now (or sh0 cron trigger <id>) starts a run immediately, whatever the schedule.
  • Deleting a job (sh0 cron delete <id>) also deletes its history.

Timeouts, overlap and limits

  • Timeout -- 300 seconds by default (--timeout in the CLI). A run that exceeds it is recorded with the status timeout and the message “Job timed out after Ns”.
  • Overlap -- while a run of a job is still in progress, the scheduler does not start another one. The skipped occurrence is not recorded in the history.
  • 50 jobs per application.
  • No retries and no failure notifications. A failed run is not retried and sends no alert: check the history, or have the endpoint called by the job report its own failures.