Deployment

Run the same schedule on PM2, a Kubernetes fleet, or an external scheduler — and generate the platform manifest with voltro schedule-manifest.

The point of the *.cron.tsx primitive is that the job definition doesn't change when the topology does. You pick a trigger + coordination per environment; the cron, timezone, and handler stay put. This page maps common hosting setups to the right configuration.

The topology matrix

Hosting trigger coordination Notes
Local dev (voltro dev) self single Memory store default.
Single VPS / one PM2 process self single No coordination needed — one process.
PM2 cluster / multiple containers self advisoryLock Postgres arbitrates. No extra infra.
Kubernetes, N replicas self advisoryLock The pragmatic default — pods race the Postgres claim.
Kubernetes, already running @effect/cluster self cluster Schedules ride the existing sharding fabric.
"All cron must be k8s CronJobs" (policy) external The cluster fires; generate a CronJob manifest.
AWS (EventBridge owns cron) external Generate an EventBridge rule.
GCP (Cloud Scheduler) / Azure (Functions timer) external Generate the matching job.

The only code that changes between any two rows is the scheduling block in app.config.ts (and occasionally a per-job trigger: override). The schedules themselves are untouched.

Self-hosted, in-process (the common case)

Nothing to deploy beyond your app. Set the coordination that matches your instance count:

// apps/api/app.config.ts
scheduling: { coordination: 'advisoryLock' }   // 2+ instances on Postgres

advisoryLock is the default on Postgres, so multi-instance "just works" — see coordination. This is the right answer for most fleets: no orchestrator coupling, no separate scheduler to operate.

The production API server starts the same scheduler engine as voltro dev. If a schedule starts a workflow, app.workflows.start(...) is available in the handler; with coordination: 'cluster', Voltro acquires the cluster-cron layer during boot so the clock does not wait for the first incoming request.

Delegating to an external scheduler

When the platform must own the clock (enterprise policy, serverless scale-to-zero), set trigger: 'external' and generate the manifest.

Kubernetes and Helm

# Templates + a values excerpt to merge into the chart you already have.
voltro schedule-manifest --provider kubernetes --base-url https://api.internal.example.com

# A standalone chart instead:
voltro schedule-manifest --provider kubernetes --format chart --base-url https://api.internal.example.com

# Plain YAML, defaults substituted, no Helm:
voltro schedule-manifest --provider kubernetes --format plain --base-url https://api.internal.example.com

# In CI: fail when the committed manifests no longer match the code.
voltro schedule-manifest --provider kubernetes --check

--format decides the shape:

--format Emits
fragment (default) One CronJob template per schedule plus values.schedules.yaml, to merge into your existing umbrella chart
chart The same templates wrapped in a standalone chart (Chart.yaml + values.yaml)
plain One YAML file with the defaults substituted and no templating

The fragment is the default because the estates that mandate this mode already have a chart per service, and adding a second release unit is usually a process change rather than a technical one.

What the generated CronJob carries, and why

Every site-specific value sits behind .Values.voltroSchedules.*, and the PodSpec is written to be admitted by a namespace running Pod Security Admission at restricted — which is the default posture in exactly the organisations that impose this rule. Concretely:

  • runAsNonRoot, seccompProfile: RuntimeDefault, allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, all capabilities dropped, and resources declared so a LimitRange namespace does not reject it.
  • startingDeadlineSeconds. Without it, a controller outage spanning 100 missed occurrences stops the CronJob scheduling permanently, and says nothing.
  • timeZone, which requires Kubernetes 1.27 or newer. The generated chart declares that floor rather than letting a cluster silently interpret the expression in the controller's own zone.
  • The image is a value. curlimages/curl is the default, but a cluster with a registry allowlist — the usual case here — points voltroSchedules.image.repository at its mirror. Set image.digest to pin by content; we ship no digest, because one we invented would produce a manifest whose image can never be pulled and a failure that presents as a registry problem.
  • The token is mounted as a file, never interpolated into argv (a command line is readable from /proc inside the container). No generated file contains a secret value; values.schedules.yaml tells you how to create the secret.
  • concurrencyPolicy: Forbid bounds concurrent trigger pods, not concurrent runs. Overlap between runs is decided by the app, fleet-wide, by the schedule's own onOverlap policy.

The container runs a small sh script that POSTs the trigger, then polls the run and exits with its outcome — a failed handler is a failed Job. A succeeded or skipped run exits zero; skipped is the overlap policy working, and paging someone for it would be wrong.

Keeping the cluster and the code in step

The cron expression lives in defineSchedule. The manifest is generated from it, so the two can only part if the generated files are not regenerated. Two things close that gap:

  • voltro schedule-manifest --check fails when the committed manifests no longer match the schedules in code. Run it in CI.
  • If a stale manifest does reach the cluster, the app refuses its triggers with 409 and names both cadences — the drift is self-detecting rather than a job quietly firing on the old schedule.

Other platforms

--provider aws | gcp | azure emit a documented recipe rather than a finished artefact: the endpoint, the cron translation and the shape of the call. They are starting points you adapt, not files you apply. Kubernetes is the one with a complete, hardened, test-rendered output.

The framework's own periodic tasks

If your rule is "every cron is a cluster object", the framework's maintenance timers are part of the question. scheduling.externalizeFrameworkTasks makes the ones that can be into CronJobs — governance.retention.sweep (1 h), licensing.refresh (15 min), voltro.workflow.staleness (5 min), search.resync (60 s).

Three cannot, and this does not change with effort: voltro.ai.inference (250 ms), voltro.workflow.admission (1 s) and voltro.workflow.cancelOn (2 s) are below the resolution of every platform scheduler. They are also not crons: their real trigger is an arrival — they wake on the change — and the interval is only a ceiling for when reactivity is unavailable. They are the workers' own drain loops. Each is refused loudly at boot and keeps its timer, because a task silently left ticking while you believe it was externalised is worse than one never externalised.

There is a cost even for the eligible ones: a coordinated task backs off when idle and can stop ticking entirely where an arrival can wake it; a CronJob is a fixed cadence and knows none of that. For slow maintenance work the difference is immaterial, which is why this is opt-in rather than a mode.

One more property to know before you rely on it: unlike a schedule, an externalised task runs inside the trigger request — it is a maintenance tick, not a handler with a 30-minute ceiling, so there is no run row to poll. A task that outlives your ingress timeout will therefore mark its Job failed while the tick carries on to completion. The retry that follows is absorbed (it claims the same interval bucket), so the cost is a false alert rather than duplicated work — but if one of your sweeps is genuinely slow, leave it on its timer.

What to deploy alongside

  • voltro migrate creates _voltro_schedule_runs (and _voltro_schedule_claims for advisoryLock) automatically — run it as part of your release, the same as your other tables. You never hand-write these migrations.
  • Set the trigger credential. POST /_voltro/schedules/<name>/trigger runs a handler, and it refuses every request until VOLTRO_SCHEDULE_TRIGGER_TOKEN is set (503, with the remedy). Set the same value on the app and in the secret the CronJobs mount. A network policy or ingress rule on top is a reasonable belt, but the token is the gate.
  • See self-hosting and Voltro Cloud for the broader deployment story.

Switching topologies later

Moving from a single box to a fleet is a config change, not a rewrite:

  scheduling: {
-   coordination: 'single',
+   coordination: 'advisoryLock',
  }

Moving cron ownership to Kubernetes:

  scheduling: {
-   trigger: 'self',
+   trigger: 'external',
  }

…then voltro schedule-manifest --provider kubernetes --base-url … and apply the output. The *.cron.tsx files don't change.