Skip to content

State management

The first question every adopter asks is: “but where does the state live?” Terraform has state.tfstate. Argo has its cluster controller. Crossplane has CRDs and a control plane. cronix has none of these — and yet cronix apply is reproducible, drift-detectable, and safe to run from any host. Where does the bookkeeping happen?

Short answer: cronix distributes state to where it most naturally lives — the backend itself. The crontab line, the systemd unit, the Kubernetes CronJob, the EventBridge Schedule, the vercel.json entry — each one is annotated with ownership information at the moment cronix creates it. The backend’s own native entries are the state. No side-channel file, no central DB, no controller running between releases.

This page is the long version of that claim. It walks through every job a Terraform-style state file does, shows where cronix puts that responsibility instead, and is honest about the tradeoffs that result.

The mapping

Terraform’s state.tfstate is a single file that bundles together seven distinct responsibilities. cronix unbundles them — each one lives in the place that best knows the answer.

Terraform state jobWhere it lives in cronix
Ownership tracking — “did I create this?”Backend-native ownership markers (see below)
ID mapping — logical name → cloud IDNot needed. Manifest app + job + index is the ID. The crontab line, systemd unit, CronJob, and EventBridge Schedule are each named/keyed by the manifest.
Drift detection — intended vs actualRe-read backend, diff against manifest. cronix plan and cronix drift — both stateless, both run from any host.
Dependency graph — apply orderingNot needed. Jobs are independent; no apply order to compute.
Concurrent-write coordinationPer-backend native primitives. File lock for crontab/systemd, K8s optimistic concurrency for CronJob, EventBridge last-write-wins. Documented v1 limitation: concurrent applies from two hosts are safe but not load-balanced.
Run history — what fired when, did it succeedBackend-native log sources: journalctl -u, K8s Events + Pod logs, CloudWatch for EventBridge. cronix history reads from whichever applies.
Sensitive data caching — secrets, outputsNever. HMAC secrets are passed by reference (secret_refs: [...]); the SDK resolves them at fire time. cronix never persists secret material to disk.

ID mapping and dependency-graph are non-issues by design — the cronix data model is flat (one schedule per (app, job, schedule-index), no dependencies between schedules). For the other five, you’ll find the per-backend implementation below.

Ownership markers (D-026)

Every backend has a place where cronix writes “this entry is owned by app=X, job=Y, schedule-index=Z, hash=H”. The marker is read on every plan and drift to answer two questions: is this mine? and is its content still in sync with the manifest?

BackendMarker location
crontabA # cronix:owned app=… job=… index=N hash=… comment line immediately following each schedule line. Block delimited by # BEGIN cronix-managed / # END cronix-managed so cronix never touches lines outside its block.
systemd-timerX-Cronix-App=, X-Cronix-Job=, X-Cronix-Index=, X-Cronix-Hash= ini annotations inside the .timer unit. Units live in a dedicated cronix-prefixed directory under /etc/systemd/system.
kubernetescronix.dev/managed: "true" label plus cronix.dev/app, cronix.dev/job, cronix.dev/index, cronix.dev/hash labels on both the CronJob and its companion ConfigMap. Standard Kubernetes controller pattern.
aws-schedulerA cronix- name prefix on the EventBridge Schedule, plus a structured Description field carrying app/job/index/hash.
vercelcronix owns the vercel.json crons[] array entirely when cronix is enabled for the project. No per-entry metadata exists in the Vercel schema; the file-level ownership is documented loudly and verified by cronix adopt.

The hash in every marker is an FNV-1a 64-bit fold over the canonical normalized job, salted by schedule index. See drift detection for how the hash is computed and used.

What happens on each cronix command

CommandWhat it readsWhat it writes
applyThe manifest (over HTTP) + every backend-native entry tagged as cronix-ownedNew or updated owned entries; never anything else
plan / diffSame as applyNothing
driftSame as applyNothing; exits 5 if anything diverges
pruneEvery owned entry; ignores the manifest entirelyDeletes owned entries that match the prune filter
historyThe backend’s own log source (journald, K8s Events, CloudWatch)Nothing
listEvery owned entry across all configured backendsNothing

Notice what’s missing: there is no path that writes to a state file. Every read goes back to the source of truth (manifest or backend); every write goes to the backend itself.

Concurrency state

There’s one category of state cronix does maintain — but only transiently, only at fire time, and only when the manifest opts in.

For jobs with concurrency: Forbid or Replace, the trigger shim must know whether another invocation is already running for the same job. cronix supports two scopes:

ScopeStorageWhen to use
host (default)flock over a local lock file in /var/lib/cronix/locks/When the job only runs on one host — crontab, systemd-timer, single-replica K8s deployments.
globalRedis (v1; pluggable later)When the same job can fire on multiple hosts and you want at-most-one running cluster-wide.

global scope does require external state — a Redis instance the trigger shim can reach. This is a documented, operator-controlled choice. The default is host, which needs nothing beyond the local filesystem.

This is the only category of state cronix touches. It’s ephemeral (the lock’s lifetime is one fire), it’s externally managed (the operator decides), and the design is explicit about it — see the concurrency page.

Honest tradeoffs

Pretending these don’t exist is what gets a “no state file” pitch torn apart by experienced operators. Each is intrinsic to the design:

1. crontab is the weakest case

crontab is a text file with no native annotation surface. The cronix-managed block (# BEGIN cronix-managed / # END cronix-managed) is the only thing keeping hand-edits outside the block safe. Lines inside the block, however, can be hand-edited and the next cronix apply will overwrite them. The block markers plus the per-line hash= are how cronix tells “this was edited” from “this is exactly what I wrote.” Adopters who want to mix hand-edited crons and cronix-managed crons should keep them in separate files (/etc/crontab for hand work, /etc/cron.d/cronix for cronix’s block).

2. Vercel ownership is opinionated

vercel.json crons[] is one flat array with no per-entry metadata. When cronix is enabled for a Vercel project, the whole array is owned by cronix. Mixing hand-written crons[] entries with cronix-managed ones in the same file is unsupported and will produce surprising results. The vercel backend page calls this out at the top.

3. No central run history

Each backend’s history is in its own native log: journalctl -u, kubectl get events, CloudWatch, Vercel’s deployment logs. cronix history is a thin reader; it does not aggregate across backends. Cross-backend queries (“did any cronix job fail in the last hour?”) require operators to glue together native log sources — there’s no cronix-side aggregator. This is a deliberate v1 non-goal documented in the RFC.

4. concurrency_scope: global requires Redis

Operators who declare any job with concurrency_scope: global must provide a Redis instance. The cronix CLI does not run Redis, doesn’t bundle it, doesn’t manage it. This is the one category where the operator is on the hook for external state. The default scope is host (no external dependency).

5. Concurrent applies are safe but not load-balanced

Two operators running cronix apply simultaneously against the same backend will not corrupt anything — flock and K8s optimistic concurrency catch the conflict — but neither will the work be merged. One wins; the other reports an error and exits non-zero. Most adopters serialize applies through CI, which never hits this.

”But Terraform’s state file is the answer to N problems cronix has too” — does it?

Sometimes. Here’s an honest breakdown:

ProblemTerraform’s answercronix’s answerTradeoff
Knowing what’s deployedRead state.tfstateRe-read the backendcronix is slower for large estates (an API roundtrip per backend); cronix is always up-to-date (no stale state file)
Detecting driftCompare config to state, then state to cloudCompare manifest to backend directlycronix’s check is fundamentally cheaper for the small-N case (cron jobs are few; cloud resources are many)
Migrating between backendsterraform state mvDifferent cronix manifest pointed at different --backendConceptually identical; cronix’s migration is a re-apply
Audit log of who changed whatState file in version controlGit of the manifest + backend-native audit logs (CloudTrail, K8s audit, journald)The audit story is better for cronix in cloud-native backends; equivalent for crontab/systemd
Locking concurrent operationsState file lock (S3+DynamoDB, Terraform Cloud)Backend-native concurrency primitivesAcceptable for typical cronix deploys (CI serializes); not for multi-operator concurrent-apply scenarios

The thing cronix gives up versus Terraform is the speed advantage of having a single source of truth that’s already cached locally. For small N (hundreds of cron jobs at most, not thousands of cloud resources), re-reading the backend is faster than the state-file-management overhead. For large N, the equation flips — but cronix is not aimed at the large-N case.

Going deeper

  • Drift detection — the hash algorithm, the diff output format, exit codes
  • Manifest format — the source-of-truth document cronix apply reads
  • Concurrency policies — when host vs global scope, when Allow/Forbid/Replace
  • Backends — per-backend ownership marker schemes in full detail
  • RFC §State — the protocol-level statements about state, normative