Back to blog

Preventing Duplicate Cron Jobs When You Scale to Multiple Servers

When your app runs on two or more servers, every cron job in the crontab fires on every machine. This is how to stop duplicate execution without creating a new single point of failure.

CronGuard TeamCron Job Monitoring Experts
8 min read
A technician in an orange safety vest working on rack-mounted equipment with indicator lights in a dimly lit server room

The problem no one mentions when you add a second server

Your cron jobs work fine on one server. You add a second one, maybe for load balancing, maybe to survive a restart, maybe because your autoscaler decided it needed more capacity. The crontab is part of your deploy, so now both servers have it. Both run every job. The nightly report goes out twice. The cleanup script deletes and re-deletes. The billing rollup charges twice.

Cron has no concept of coordination. Each instance reads its own crontab, keeps its own internal clock, and fires jobs independently. Two servers with identical crontabs behave exactly like two separate machines running the same script on the same schedule, because that is what they are.

Here are the approaches teams actually use, starting from the simplest.

Option 1: Designate one server as the cron host

Install the crontab on one machine only and leave the others without it. Teams usually do this by splitting roles: one dedicated worker instance runs cron, the web servers do not.

Simple, and easy to reason about. But it reintroduces the single point of failure that horizontal scaling was meant to remove. If your cron host is down during the 3 AM billing window, nothing runs.

Most teams pair this with a health check, which just moves the question: how do you monitor a machine that is supposed to be running cron jobs? That answer involves dead man's switch monitoring anyway.

Option 2: File locking with flock

On a single server, flock prevents a second instance from starting while the first is still going:

*/5 * * * * /usr/bin/flock -n /tmp/invoice-export.lock /opt/app/scripts/invoice-export.sh

The -n flag is non-blocking: if another process holds the lock, flock exits immediately. The job skips that invocation.

This is the right tool for preventing overlapping runs on one machine. It does nothing across machines. Each server has its own /tmp, and the lock file on server A is invisible to server B.

You can mount a shared NFS directory and put the lock file there. That works on quiet days. But now your cron jobs depend on the availability of the NFS share. If the mount hangs, every job hangs with it.

Option 3: PostgreSQL advisory locks

PostgreSQL advisory locks let you take a session-level lock against an arbitrary integer. Unlike row-level locks, they do not attach to any table row. The database tracks who holds what, and releases the lock automatically when the session closes.

#!/bin/bash
LOCK_ID=12345  # pick a unique integer per job

psql "$DATABASE_URL" <<'SQL'
  SELECT pg_try_advisory_lock(12345);
SQL

# pg_try_advisory_lock returns true if acquired, false if already held
if psql -t -A "$DATABASE_URL" -c "SELECT pg_try_advisory_lock(12345);" | grep -q 'f'; then
  echo "Another instance is running, exiting"
  exit 0
fi

# do the actual work here
/opt/app/scripts/invoice-export.sh

psql "$DATABASE_URL" -c "SELECT pg_advisory_unlock(12345);"

If the script crashes without unlocking, the lock disappears when the session closes. No stale locks to clean up manually. Any server that can reach the database can participate in the coordination.

The cost is a database roundtrip before every job starts. For most scheduled tasks that is negligible. For sub-minute jobs, it adds up.

Option 4: Redis SET NX with expiry

The same pattern in Redis uses SET key value NX EX seconds:

#!/bin/bash
LOCK_KEY="cron:invoice-export"
LOCK_TTL=3600  # 1 hour, longer than the job should take

acquired=$(redis-cli SET "$LOCK_KEY" "$(hostname)-$$" NX EX "$LOCK_TTL")

if [ "$acquired" != "OK" ]; then
  echo "Lock not acquired, another instance is running"
  exit 0
fi

/opt/app/scripts/invoice-export.sh

redis-cli DEL "$LOCK_KEY"

NX means "only set if the key does not already exist." EX sets the key to expire after a fixed number of seconds. If the script dies before calling DEL, the lock expires on its own after the TTL.

The TTL needs thought. Set it too short and a slow job releases its own lock mid-run, letting a second instance start. Set it too long and a crash leaves the system blocked until the TTL expires. Two or three times the job's normal runtime is a reasonable starting point.

Watching for silent exits

Both patterns share a failure mode: a server that fails to acquire the lock exits with code 0 and no output. From your monitoring's point of view, the job ran on schedule. If the lock holder is stuck or dead, nothing actually completed.

Dead man's switch monitoring catches this. The lock tells you whether the job ran. A heartbeat tells you whether it finished.

Option 5: Move scheduling out of cron entirely

Advisory locks and Redis patterns are coordination bolted onto a tool that was designed to run independently on each machine. They work, but they are workarounds.

pg_cron runs inside PostgreSQL. There is only one database, so there is only one scheduler, and jobs fire once per schedule. You register jobs with SQL:

SELECT cron.schedule('invoice-export', '0 3 * * *', 'SELECT export_invoices()');

AWS EventBridge Scheduler and GCP Cloud Scheduler are managed services that invoke a single HTTP endpoint on schedule. The cloud service fires once and your application handles the request. No fleet of servers each running their own crontab.

Oban for Elixir, River for Go, and Sidekiq Enterprise for Ruby include scheduler components that use leader election or database locks to enqueue each scheduled job exactly once. Your code defines the job and the schedule; the library handles coordination.

All of these give up cron's simplicity in exchange for built-in coordination. For teams already running PostgreSQL, pg_cron is usually the lowest-friction starting point.

What to monitor regardless of approach

Track whether jobs are actually completing. A job that exits because it failed to acquire a lock looks identical to one that exits cleanly, because both exit with code 0. Heartbeat monitoring catches the case where no lock holder ever ran to completion.

Also watch lock duration. If a job normally takes two minutes but the advisory lock has been held for forty, something is wrong with the job itself. Lock implementations do not surface this automatically.

For PostgreSQL advisory locks, this query shows what is currently held:

SELECT pid, now() - state_change AS lock_duration, query
FROM pg_stat_activity
WHERE state = 'active'
AND query LIKE '%advisory%';

For Redis, TTL cron:invoice-export shows how much time remains on the lock. If that number decreases slower than expected, the job is running long.

Frequently asked questions about preventing duplicate cron jobs

Does Kubernetes solve duplicate cron runs automatically? A Kubernetes CronJob creates one Job object per scheduled invocation, and the Job controller starts the specified number of pods. With the default concurrencyPolicy of Allow, two scheduled invocations can overlap, and with replicas greater than one, multiple pods run the same work. Setting concurrencyPolicy to Forbid prevents overlapping invocations, but does not prevent multiple pods within a single Job from running the same code simultaneously unless your application handles it.

What happens if the lock holder crashes mid-job? PostgreSQL advisory locks are released automatically when the database session closes, which happens when the process holding the connection exits, whether cleanly or via crash. Redis TTL-based locks expire after the configured timeout. Neither approach leaves a permanent stale lock, but there is always a window between crash and lock release during which no new instance can start. Size the timeout accordingly, or expect to wait out the session close.

Is Redis reliable enough for distributed locking? For most cron job use cases, yes. The single-node SET NX pattern is safe when you have one Redis instance and can tolerate the job not running if Redis is unavailable. The Redlock algorithm across multiple Redis nodes is theoretically safer but harder to operate correctly and has known edge cases under network partitions. For jobs where exactly-once execution matters, the PostgreSQL advisory lock is simpler and uses infrastructure you likely already depend on for correctness.

Can I use flock across NFS to coordinate between servers? You can, and NFS-based flock does work in practice on many systems, but it is fragile. NFS locking depends on the lockd daemon, which has its own availability requirements. A stale NFS mount hangs the locking call rather than failing it, which means your job hangs rather than skipping the invocation. Teams that have tried this tend to migrate off it after the first outage.

Why does my load balancer make this worse? A load balancer does not change cron execution at all, because cron runs on the host, not on the connection layer. But load balancers make it easy to add servers, so teams add servers, and suddenly every server is running the same crontab. The load balancer is not the cause; horizontal scaling is.

Further reading


Conclusion: There is no single right answer for preventing duplicate cron jobs at scale. Designating one cron host is simple but fragile. File locking with flock works on one machine. Database advisory locks and Redis SET NX both give you cross-server coordination with automatic cleanup on failure. Moving to a coordination-aware scheduler — pg_cron, a managed cloud scheduler, or a durable job queue — removes the problem at the root. Any of these approaches needs monitoring to confirm that work is actually completing, not just that the lock is being acquired.

Sources: pg_try_advisory_lock — PostgreSQL documentation, SET — Redis documentation, flock(1) — Linux manual page.

Share

Related posts

Close-up of a numbered network patch panel with blue and gray Ethernet cables plugged into labelled ports
DevOpsKubernetes Job Suspension and Why It Creates a Monitoring Blind Spot
A dark server rack lit green from within, showing rows of patch-panel ports with black and colored network cables looped between them, and a blurred second rack on the left
ReliabilityDurable Job Queues Are Replacing Cron: New Failure Modes
Close-up of a high-density network switch panel with rows of small ports, several aqua fiber-optic cables plugged in, and orange status lights glowing along the rows
ReliabilityTime Zone Changes Are Moving When Your Cron Jobs Run

Set up your first monitor.
It'll take 30 seconds.