---
title: "Schedule a job"
summary: "How to declare a scheduled job, why it runs from a host timer rather than a Onebox process, and which cron expressions are refused at load."
description: "Host timers that survive a reboot, and the cron forms Onebox refuses."
status: shipped
read_when:
  - "Adding a nightly or recurring task"
  - "Understanding why a cron expression was refused"
  - "Working out how a scheduled job resolves secrets with no Onebox process running"
---
```yaml
workloads:
  nightly-dump:
    role: job
    image: postgres:17
    command: ["sh", "-c", "pg_dump \"$POSTGRES_URL\" | gzip > /backups/$(date -u +%F).sql.gz"]
    data_effect: none
    needs: [postgres]
    volumes: [{name: backups, path: /backups}]
    schedule: {cron: "0 2 * * *", timezone: Europe/Berlin}
```

## It becomes a host timer

A `schedule` is translated into a timer on the host. It fires **without any
Onebox process running** and survives a reboot. There is no daemon, no listening
port, and nothing to keep alive.

That design decision has a visible consequence elsewhere: encrypted environment
entries are decrypted into the release when it is staged and *stay* there,
because a timer firing at 02:00 must resolve the values the deploy resolved.

## Cron is translated exactly, or refused

```
0 2 * * *      ✓  every day at 02:00
0 2 * * 1      ✓  every Monday at 02:00
0 2 1 * 1      ✗  schedule_untranslatable
```

The last one declares a day-of-month **and** a day-of-week. Cron treats that as
"either matches" — it fires on the 1st and on every Monday. Onebox refuses it at
load rather than running on days nobody chose.

`timezone` takes an IANA zone name and defaults to `UTC`.

## Jobs still declare a data effect

`data_effect` is required on every job, scheduled or not. A nightly report is
`none`; a nightly prune is `destructive`. The rollback and abort gates read it,
and a job that lies about it defeats them.

## Running one by hand

```sh
ob job plan nightly-dump --out ob-job-plan.json
ob approve --plan ob-job-plan.json --out ob-job-approval.json
ob job run --plan ob-job-plan.json --approval ob-job-approval.json
```

`ob job plan` accepts a job only when its resolved `when` is `manual`, which is
the default for a job that declares none. A `pre_release` or `post_release` job
is refused: it belongs to the deploy graph, and one-shot invocation is reserved
for the jobs that do not.

A declared `when: manual` job remains in the digest-pinned release runtime but
never joins the deploy graph. Its job plan binds the current serving release,
runtime digest, immutable image, data effect, target, and expiry. Automation
supplies the saved plan and its separately recorded local confirmation;
migration jobs may also need the exact plan-bound backup report.

For an undeclared emergency command, the escape hatch remains:

```sh
ob exec --reason "incident investigation" <workload|service> -- <command>
```

> **`ob exec` is an audited escape hatch**
>
> Nothing it changes belongs to a release and Onebox cannot describe, roll back,
> or make its effect idempotent. The journal stores the reason, target, operator,
> outcome, and command digest, but never the command bytes or passthrough output.
> The reason is durable metadata, so do not put a secret in it.

For a job that participates automatically in a deploy, choose a release phase:

```yaml
when: pre_release    # or post_release; manual is invoked only through job plan/run
```

`schedule` and `when` are orthogonal, and both fire. A job declaring `schedule:`
**and** `when: pre_release` runs on the host timer at its cron time *and* again
on every deploy. Declaring both is how you ask for both; for the timer alone,
leave `when` at its `manual` default.

See [`workloads`](/reference/fields/workloads) for every field.