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 PostgresadvisoryLock 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, andresourcesdeclared so aLimitRangenamespace 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/curlis the default, but a cluster with a registry allowlist — the usual case here — pointsvoltroSchedules.image.repositoryat its mirror. Setimage.digestto 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/procinside the container). No generated file contains a secret value;values.schedules.yamltells you how to create the secret. concurrencyPolicy: Forbidbounds concurrent trigger pods, not concurrent runs. Overlap between runs is decided by the app, fleet-wide, by the schedule's ownonOverlappolicy.
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 --checkfails 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
409and 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 migratecreates_voltro_schedule_runs(and_voltro_schedule_claimsforadvisoryLock) 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>/triggerruns a handler, and it refuses every request untilVOLTRO_SCHEDULE_TRIGGER_TOKENis 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.