Most recurring work is idle most of the time. A nightly report runs for four minutes, and then a machine sits idle for the remaining twenty-three hours and fifty-six minutes, doing nothing but incurring cost. A CronJob paired with a burst pool closes that gap: the pool holds no nodes until the schedule fires, the autoscaler brings one up to run the Job, and removes it again once the Job finishes. You pay only for the four minutes. ## Idle-node fit Point the CronJob at a worker pool that is allowed to [scale to zero nodes](/docs/hetzner/apalla/workloads/scaling/scale-to-zero), and the lifecycle takes care of itself. ```mermaid flowchart LR A[Schedule fires] --> B[Job created, pod Pending] B --> C[Autoscaler provisions a node] C --> D[Job runs to completion] D --> E[Pool scales back to zero] ``` Between runs there is nothing to pay for and nothing to patch. This is the autoscaler managing capacity, not Knative's request-driven scale-to-zero: here the trigger is the clock, not incoming traffic. The one delay to plan for is provisioning: a fresh node takes a couple of minutes to come up, so a job that must start _exactly_ on the second needs a node already warm. ## Schedule and timezone ```yaml title="cronjob.yaml" apiVersion: batch/v1 kind: CronJob metadata: name: nightly-report spec: schedule: "0 2 * * *" # 02:00 every day timeZone: "Europe/Berlin" # without this, the schedule is UTC jobTemplate: spec: template: spec: restartPolicy: Never # batch pods run once; never restart in place containers: - name: report image: your/report:tag ``` The `schedule` field is ordinary cron: minute, hour, day-of-month, month, day-of-week. Set `timeZone` explicitly. Leave it off and the schedule runs in UTC, so a job you wrote as "2 a.m." fires at the wrong local hour, and drifts by an hour again at every daylight-saving change. ## Control overlap with concurrencyPolicy A schedule says nothing about what happens when a run is still in progress and the next one comes due. `concurrencyPolicy` decides: - **`Allow`** (default) starts the next run regardless, so two can overlap. - **`Forbid`** skips the new run until the previous one finishes. This is what most backups and syncs need: two copies writing at once is how state becomes corrupted. - **`Replace`** terminates the running one and starts a fresh run. ## Missed runs and history If the cluster or the CronJob controller was down when a run was due, `startingDeadlineSeconds` governs the recovery. A run that is more than that many seconds late is skipped rather than fired, so a brief outage does not end with the controller launching a backlog of catch-up jobs all at once when it recovers. Finished Jobs remain for inspection, capped by `successfulJobsHistoryLimit` and `failedJobsHistoryLimit` (default 3 and 1). Keep them low; see [cleanup](/docs/hetzner/apalla/workloads/batch/backoff-and-ttl-cleanup) for why finished objects should not accumulate. When you need to stop a schedule without deleting it (a maintenance window, a paused pipeline), set `suspend: true` and re-enable it later. ## Trigger a run without waiting You do not have to wait until 2 a.m. to confirm it works. Trigger a Job directly from the CronJob spec: ```console $ kubectl create job --from=cronjob/nightly-report test-run ``` That runs the exact `jobTemplate` on demand. Watch it land on a freshly provisioned node, complete, and let the pool drain back to zero: the whole idle-node lifecycle, on your schedule instead of the cron one.