Skip to content
findplate

DocsModules

Jobs

Background jobs and cron on your existing Postgres with pg-boss, typed with zod.

On this page

The jobs module adds packages/jobs: queues, retries and cron on top of the Postgres you already run, through pg-boss. No Redis. It is off by default.

findplate create my-app --modules jobs

pg-boss keeps its own pgboss schema in the same database and creates or migrates it when the api starts, so there is no drizzle migration to generate.

Define a job

A job is a queue name, a zod payload and a handler. Put it in packages/jobs/src/jobs/ and add it to JOBS in packages/jobs/src/jobs/index.ts.

export const welcome = defineJob({
  name: "send-welcome",
  description: "Welcome mail after sign up",
  payload: z.object({ userId: z.string() }),
  retry: { limit: 5, delaySeconds: 30, backoff: true },
  handler: async ({ userId }, { db }) => {
    // db is the same drizzle instance the api uses
  },
})

Enqueue it from the api with the typed send, the payload is checked at compile time and again at runtime:

await services.jobs.send(welcome, { userId })

retry is limit extra attempts after the first failure, waiting delaySeconds, doubled each time when backoff is on. A job that keeps failing stays visible as failed in the admin list. The default is 3 retries from 30 seconds with backoff.

Cron

Add cron and scheduleData to a definition and the worker registers the schedule on start (cron expressions are evaluated in UTC):

defineJob({ name: "digest", cron: "0 8 * * 1", scheduleData: {}, ... })

The sample cleanup-sessions job runs every night at 03:00 and deletes expired sessions. echo has a payload and no side effect, handy to see the worker run. Removing a job from the code does not remove its schedule from the database, run delete from pgboss.schedule where name = '<job>' once.

Admin procedures

Admins only, also reachable over REST under /v1:

ProcedureWhat it does
jobs.listevery job with its cron and queued, active and failed counts
jobs.enqueue{ name, payload }, checks the payload against the job's schema and answers BAD_REQUEST when it doesn't fit

Running the worker

By default the api process runs the worker and the cron scheduler itself, which is right for one container and for local dev. To scale or isolate them, turn it off on the api and run the worker apart:

JOBS_WORKER=false   # on the api, it then only enqueues
  • locally: pnpm dev:worker
  • anywhere: bun apps/api/src/worker.ts
  • Docker: the api Dockerfile has a worker target, docker build --target worker -f apps/api/Dockerfile .. It is the same image without the http server and the health check.
  • Dokploy: add a second application from the same repo, set the Dockerfile to apps/api/Dockerfile, the build target to worker, and give it the same env as the api. It exposes no port, so skip the domain.

Run as many workers as you like, pg-boss hands each job to one of them and runs each cron tick once.

Graceful shutdown

On SIGTERM or SIGINT the api stops accepting requests, waits up to 8 seconds for running jobs to finish and exits, which fits the 10 seconds Docker gives a container. Jobs still running after that are failed and retried by another worker, so write handlers that are safe to run twice.

Env keys

JOBS_WORKER=true