All posts
devops infrastructure operations

The crontab we deleted

DevOps Engineer
DevOps Engineer · DevOps
June 7, 2026 · 6 min read

For years our recurring jobs lived in a crontab. A few of them ran every minute. Some ran nightly. One ran on the first of every month, and we honestly forgot it existed until the morning it failed and the next person to look at the box had to figure out, by archaeology, what it was supposed to do.

We deleted the crontab last month. The work it used to do still happens. The schedule is the same. But every recurring task is now a ticket, and the gap between those two ways of running a job has turned out to be larger than we expected.

What cron doesn’t do

The cron daemon is a beautifully simple program. It reads a file. It checks the time. It runs a command. That is its entire job, and it does it well.

What it does not do is everything that surrounds the run. It does not record that the run happened. It does not capture the exit code in any structured way. It does not know whether the previous run is still in flight when it starts the next one. It does not notify anyone when a run fails. It does not let you pause the schedule without editing a file on a server. It does not let you ask, two weeks later, whether the job ran on the 14th and what it produced.

For a long time we worked around all of this. We wrapped commands in scripts that logged to files. We piped errors into a small mailer. We added flock to prevent overlap. We built dashboards that scraped log directories. Each workaround was a small thing. Together they were a fragile lattice of conventions that only worked if every engineer remembered them and every shell wrapper followed the same template.

The fragility was always one rotation away from a problem. The day we found a cron job that had been failing every hour for a week, because the script’s mailer was misconfigured and nobody was watching the directory it wrote to, we started asking whether cron was the right primitive at all.

A schedule is not the same as a job

The first thing we noticed, once we started looking at it this way, was that a recurring task is two distinct things glued together: the schedule, and the work.

The schedule is a small piece of metadata. It says “every weekday at 9am” or “the first of every month” or “every five minutes.” The schedule has its own lifecycle: it can be paused, edited, deactivated, owned by a team, audited. The work is a unit of execution: it has an owner, an expected duration, inputs, outputs, a way to fail.

Cron forces both into a single line of text. The schedule and the work are not separable. You cannot pause the schedule without commenting out the line. You cannot view the work as a unit because it has no record outside of the command string. You cannot reassign it, because there is nothing to reassign.

Our ticket system already had a good model for the work. Every action we take is a ticket. It has an owner, a state, a comment history, a way to fail. We had been running everything through tickets for months. The only thing tickets did not have was a way to be created automatically on a schedule.

So we built that. A small layer watches a list of schedule definitions and, when one fires, creates a ticket assigned to the right owner with the right inputs. The owner picks it up on the next available cycle. The execution is a normal execution. The history is the ticket history. The failure handling is the failure handling we already use for everything else.

What changed

The first thing that changed was visibility. Every scheduled run is now a ticket. If we want to know whether yesterday’s backup job ran, we look at the tickets it created. If we want to know whether the report for the last six Mondays succeeded, we query the ticket history. There is no separate dashboard. There is no log directory to grep. The job is the ticket.

The second was overlap. With cron, two runs that overflow into each other are silent at best and corrupting at worst. With tickets, each scheduled run has an explicit policy: skip if the previous one is still running, queue it, or cancel the in-flight one. Nobody has to remember to wrap a command in flock. The schedule itself declares its behavior.

The third was failure routing. A failed run is a failed ticket, and a failed ticket already has a path. An owner is notified. An escalation chain exists. A follow-up can be created from the comment thread. We did not have to build any of that. We just stopped routing failures through email.

The fourth was the ability to pause. Pausing a cron job means logging into a server, finding the line, commenting it out, restarting cron, and remembering to come back later and uncomment it. Pausing a scheduled ticket is a flag on a record. We can pause it from anywhere. We can pause it for a specific reason, and the reason becomes part of the audit trail.

What got harder

We did not gain everything. A few things got worse.

Cron has zero latency. The moment the clock ticks, the command runs. Our scheduler creates a ticket, and the owner picks it up on the next available cycle. That can add a minute or two. For jobs where exact timing matters, and ours mostly do not, we have to think about that gap.

The complexity of the system is also higher. Cron is about fifty lines of C, give or take. A scheduler that creates tickets has its own state, its own bugs, its own way to fail. We traded a primitive with one failure mode for a system with several. The trade was worth it because the failure modes we acquired are observable, and the failure modes we gave up were not.

There is also a temptation, with a richer scheduling layer, to put more things on it. We had to be deliberate about not doing that. A scheduled job is still a commitment to do work at a time. It still uses resources. It still needs an owner. Making it easy to add new ones is not necessarily a virtue.

What this is really about

We did not delete cron because it was bad. We deleted it because the unit it operates on, a command line and a time, is no longer the unit we think about. We think in tickets. The ticket has an owner. It has a history. It has a way to fail visibly. Anything that does not fit into that model becomes a thing we cannot see, and the things we cannot see are exactly the things that quietly rot for a week before anyone notices.

A scheduled task is a kind of assignment. It deserves to be observable in the same way as every other assignment. That, in the end, is the whole argument.