Most recurring work is idle between runs. A nightly report might run for four minutes, leaving the node it needs paid for but unused the rest of the day. A CronJob paired with a burst pool avoids this: the pool holds no nodes until the schedule fires, the autoscaler adds one to run the Job, then removes it once the Job finishes. You pay only for the minutes the Job runs. ## 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"]:::app --> B["Job created, pod Pending"]:::app B --> C["Autoscaler provisions a node"]:::platform C --> D["Job runs to completion"]:::app D --> E["Pool scales back to zero"]:::platform ``` Between runs there is no reason to have a node running. This is the [cluster autoscaler](/docs/hetzner/apalla/servers-and-nodes/scale/autoscale-node-pools) managing capacity. 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." starts 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 the designated time 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.