1---
2title: Jobs & CronJobs
3description: Run work to completion with Kubernetes.Job, as a container image or as an Effect program, and on a schedule as a CronJob. Covers how Job names, re-runs, retries, and cleanup work.
4---
5
6[`Kubernetes.Job`](/providers/kubernetes/job) runs work until it
7finishes, such as migrations, seeds, smoke tests, and reports. It
8applies a **ServiceAccount** and a Kubernetes **Job**. When you set
9`schedule`, it applies a **CronJob** instead.
10
11A Job runs one of two things:
12
13- **A container image.** It can be any image, with its own
14 entrypoint and arguments. Nothing about the program needs to know
15 about Alchemy.
16- **An Effect program.** It's an Effect you write in the same
17 codebase, bundled into an image and run in the pod. It can read
18 configuration, call other services, and use bindings.
19
20## Run a container image
21
22Pass `image`, and optionally `command` and `args`:
23
24```typescript
25const migrate = yield* Kubernetes.Job("Migrate", {
26 cluster,
27 name: "migrate",
28 namespace: "apps",
29 image: "ghcr.io/acme/migrator:v3",
30 args: ["up"],
31 env: { DATABASE_URL: db.connectionString },
32 backoffLimit: 2,
33});
34```
35
36The cluster's nodes pull the image as written, so this works on any
37cluster. It takes the same image, environment, resource, and pod
38template props as a [Deployment](/kubernetes/workloads/deployments),
39without the port and Service.
40
41## Run an Effect program
42
43Declare the Job in its own module with `main: import.meta.url` and an
44implementation that returns `{ run }`. This example assumes
45`src/infra.ts` exports the cluster and an `Api` Deployment, as
46described in [Why the props are an Effect](#why-the-props-are-an-effect):
47
48```typescript
49// src/Seed.ts
50import * as Kubernetes from "alchemy/Kubernetes";
51import * as Effect from "effect/Effect";
52import { Api, Cluster } from "./infra.ts";
53
54export default Kubernetes.Job(
55 "Seed",
56 Effect.gen(function* () {
57 const cluster = yield* Cluster;
58 return {
59 cluster,
60 main: import.meta.url,
61 name: "seed",
62 backoffLimit: 2,
63 };
64 }),
65 Effect.gen(function* () {
66 const api = yield* Api;
67 const apiService = yield* api.serviceName;
68
69 return {
70 run: Effect.gen(function* () {
71 const service = yield* apiService;
72 yield* Effect.log(`seeding records through ${service}`);
73 }).pipe(Effect.orDie),
74 };
75 }),
76);
77```
78
79Then yield it from your Stack with `yield* Seed`.
80
81On deploy, Alchemy bundles the module, builds an image for the
82cluster's nodes, pushes it to the cluster's registry, and applies the
83Job. In the pod, a generated entrypoint runs `run` to completion and
84exits:
85
86- **Success.** The process exits 0 and the Job completes.
87- **Failure.** A failed or defected `run` exits non-zero, which
88 Kubernetes counts against `backoffLimit`.
89
90Yielding a resource attribute such as `api.serviceName` in the
91implementation binds it to the Job. Alchemy resolves the value at
92deploy time and delivers it to the pod, and `run` reads it by
93yielding the returned Effect. The binding also makes the Job depend
94on the resource.
95
96`run` can use `HttpClient` to call other services. It can also use the Bun
97platform services, such as the file system and child processes. A
98Job has nowhere to return errors, so `run` must handle them or turn
99them into defects with `Effect.orDie`.
100
101### What the cluster needs
102
103The image has to be pushed somewhere the nodes can pull from:
104
105- **[`Kubernetes.LocalCluster`](/kubernetes/clusters/local).** It has
106 a built-in registry, so there's nothing to configure.
107- **Any other cluster.** It needs a
108 [`registry`](/kubernetes/clusters/registries) on the connection,
109 such as `ghcr.io/acme`.
110- **[Amazon EKS](/kubernetes/clusters/eks).** It uses ECR, which is
111 created for you.
112
113The generated entrypoint imports `@effect/platform-bun`, so install
114it alongside `alchemy` and `effect`.
115
116### Why the props are an Effect
117
118The props are an Effect so the module can `yield*` shared resources
119that the Stack also uses, such as the cluster, a namespace, or a
120Deployment to call. Declare those shared resources at module scope in
121a file such as `src/infra.ts`. Each `yield*` resolves to the same
122resource. When the props don't reference anything, pass a plain
123object instead.
124
125### Tagged Jobs
126
127The tagged form declares the Job as a class, so other code can depend
128on it by type and the implementation is provided as a Layer:
129
130```typescript
131export class Backfill extends Kubernetes.Job<Backfill>()("Backfill") {}
132
133export default Backfill.make(
134 { cluster, main: import.meta.url, backoffLimit: 1 },
135 Effect.gen(function* () {
136 return { run: Effect.log("backfilling") };
137 }),
138);
139```
140
141## When a Job runs
142
143Kubernetes starts a Job as soon as it's created. Alchemy creates a
144new Job whenever what it runs changes. A Job runs:
145
146- On the first deploy.
147- On every deploy that changes its spec. That means the image,
148 `args`, `env` values, resources, or pod template. `env` values
149 include Outputs that resolved to new values.
150- For Effect programs, on every deploy that changes the program or
151 anything it imports.
152
153A Job doesn't run on deploys that leave it unchanged. The deploy
154doesn't wait for the Job to finish. Follow it with
155`kubectl wait --for=condition=complete job/<jobName>` and
156`kubectl logs job/<jobName>`.
157
158## Job names
159
160A Job's pod template can't change after the Job is created, so
161Alchemy names each one-shot Job after its spec. `jobName` is the base
162name plus the first eight characters of a hash, for example
163`seed-5133e709`. A changed spec produces a new name. Alchemy applies
164the new Job and deletes the previous one along with its pods.
165
166Kubernetes caps Job names at 63 characters, so the base name is
167truncated to fit. Read the current name from the `jobName`
168attribute rather than predicting it.
169
170## Retries, restarts, and cleanup
171
172- `backoffLimit` sets how many failed pods Kubernetes retries before
173 marking the Job failed.
174- `restartPolicy` sets what happens after a failure. `"Never"` is the
175 default and starts a new pod. `"OnFailure"` restarts the container
176 in place.
177- `ttlSecondsAfterFinished` makes Kubernetes delete the finished Job
178 this many seconds after it completes or fails.
179
180With `ttlSecondsAfterFinished`, Kubernetes removes the Job object on
181its own. Alchemy tracks the Job through its ServiceAccount, so a
182garbage-collected Job is not treated as missing and does not run
183again until its spec changes.
184
185## Run on a schedule
186
187Set `schedule` to a standard five-field cron expression and Alchemy
188applies a `CronJob` instead. It works for both kinds of Job:
189
190```typescript
191// an image
192const nightly = yield* Kubernetes.Job("NightlyReport", {
193 cluster,
194 name: "nightly-report",
195 image: "ghcr.io/acme/reports:v2",
196 schedule: "0 3 * * *",
197 backoffLimit: 1,
198 ttlSecondsAfterFinished: 86_400,
199});
200```
201
202```typescript
203// an Effect program
204export default Kubernetes.Job(
205 "Heartbeat",
206 Effect.gen(function* () {
207 const cluster = yield* Cluster;
208 return {
209 cluster,
210 main: import.meta.url,
211 name: "heartbeat",
212 schedule: "*/5 * * * *",
213 };
214 }),
215 Effect.gen(function* () {
216 return { run: Effect.log("still here") };
217 }),
218);
219```
220
221`backoffLimit`, `restartPolicy`, and `ttlSecondsAfterFinished` apply
222to each Job the CronJob starts. A CronJob can be updated in place, so
223it keeps a stable name. That name is the base name, truncated to 52
224characters so Kubernetes can append its own suffix to each Job it
225starts. Changing the program updates the CronJob's image, and the
226next scheduled run uses it.
227
228Schedules are evaluated in the time zone of the cluster's controller
229manager, which is UTC on most hosted clusters. The CronJob
230`timeZone` field isn't exposed as a prop. If you need it, write the
231CronJob as a [Manifest](/kubernetes/objects/manifests).
232
233Adding or removing `schedule` switches between a one-shot Job and a
234CronJob. Alchemy applies the new object and deletes the old one.
235
236## Attributes
237
238- `jobName` is the name of the Job or CronJob object.
239- `kind` is `"Job"` or `"CronJob"`.
240- `schedule` is the cron schedule, or `undefined` for a one-shot Job.
241- `serviceAccountName` is the ServiceAccount the pods run as.
242- `namespace` is the namespace of the objects.
243- `imageUri` is the image the pods run.
244- `connection` is the cluster connection.
245- `kubernetesObjects` holds references to the applied objects.
246
247## Where next
248
249- **[Tutorial part 3](/kubernetes/tutorial/part-3).** Builds an
250 Effect smoke test and a scheduled Effect Job on a local cluster.
251- **[Configuration & bindings](/kubernetes/workloads/bindings).**
252 Pass values and credentials to a Job.
253- **[`Job` reference](/providers/kubernetes/job).** Lists every prop
254 and attribute.