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.
- Open Cron Jobs in the sidebar and click Create Cron Job.
- Name -- a label, for example
nightly-report. - Application -- the app the job belongs to. Without an application, only HTTP commands are accepted (see below).
- Schedule -- a 5-field cron expression, evaluated in UTC.
- Command -- an HTTP request (
curl,wgetor a bare URL), or a program to run in a container. - 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:
$ 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:23Where 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
curlorwget, or a bare URL, is not executed by a shell: sh0 sends the request itself, from the server, with a 30-second limit.-X,-Hand-dare 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 baremy-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.
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.
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/nodeGREETING 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/nulland2>&1are removed before the check, so a line pasted from a crontab is accepted. - 4,096 characters at most.
$ 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.
curl -s -X POST -H "Authorization: Bearer <token>" http://my-api.internal:3000/internal/cleanupmy-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:
┌───────────── minute (0-59)
│ ┌───────────── hour (0-23)
│ │ ┌───────────── day of month (1-31)
│ │ │ ┌───────────── month (1-12)
│ │ │ │ ┌───────────── day of week (0-6, Sunday = 0)
│ │ │ │ │
* * * * *| Symbol | Meaning | Example |
|---|---|---|
| * | Every value | * * * * * = every minute |
| , | List of values | 0 8,12,18 * * * = 8am, noon, 6pm |
| - | Range | 0 9-17 * * * = hourly from 9am to 5pm |
| / | Step | */15 * * * * = every 15 minutes |
Common Patterns
# 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-50 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>.
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 (
--timeoutin the CLI). A run that exceeds it is recorded with the statustimeoutand 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.