Cron Jobs in Production: Setting Up Scheduled Tasks the Right Way
Why Cron Jobs Are Critical for Modern Apps
Every production app needs scheduled tasks. Whether you're cleaning up old data, sending email digests, processing payments, or backing up databases, cron jobs are the workhorses that keep your app running smoothly behind the scenes.
But here's the thing - most developers treat cron jobs like an afterthought. They'll spend weeks perfecting their API endpoints, then throw together a quick cron job with zero monitoring or error handling. Don't be that developer.
The Anatomy of a Production-Ready Cron Job
1. Proper Error Handling and Logging
Your cron jobs should never fail silently. Every job needs comprehensive logging and error handling:
#!/bin/bash
set -euo pipefail
LOG_FILE="/var/log/myapp/cleanup-$(date +%Y%m%d).log"
echo "[$(date)] Starting cleanup job" >> "$LOG_FILE"
# Your actual job logic here
if ! /usr/local/bin/cleanup-script.sh >> "$LOG_FILE" 2>&1; then
echo "[$(date)] ERROR: Cleanup job failed" >> "$LOG_FILE"
# Send alert to your monitoring system
curl -X POST "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" \
-H 'Content-type: application/json' \
--data '{"text":"Cleanup cron job failed on $(hostname)"}'
exit 1
fi
echo "[$(date)] Cleanup job completed successfully" >> "$LOG_FILE"
The set -euo pipefail is crucial - it makes your script exit immediately if any command fails, prevents using undefined variables, and ensures pipeline failures are caught.
2. Idempotency is Everything
Your cron jobs should be idempotent - running them multiple times should produce the same result. This prevents disasters when jobs overlap or get triggered accidentally:
# Bad: This could create duplicate records
def send_daily_digest():
users = get_all_users()
for user in users:
send_email(user, generate_digest())
# Good: Check if digest was already sent today
def send_daily_digest():
today = datetime.now().date()
users = get_users_without_digest_today(today)
for user in users:
digest = generate_digest(user)
send_email(user, digest)
mark_digest_sent(user.id, today)
3. Lock Files Prevent Chaos
Nothing ruins your day like overlapping cron jobs. Use lock files to prevent multiple instances:
#!/bin/bash
LOCK_FILE="/tmp/myapp-cleanup.lock"
# Check if already running
if [ -f "$LOCK_FILE" ]; then
echo "Job already running, exiting"
exit 1
fi
# Create lock file
echo $$ > "$LOCK_FILE"
# Cleanup lock file on exit
trap 'rm -f "$LOCK_FILE"' EXIT
# Your job logic here
echo "Running cleanup..."
sleep 300 # Simulate long-running task
echo "Cleanup complete"
Cron Scheduling: Beyond the Basics
Understanding Cron Syntax
The classic format: minute hour day-of-month month day-of-week command
# Every day at 2:30 AM
30 2 * * * /path/to/script.sh
# Every Monday at 6 AM
0 6 * * 1 /path/to/weekly-job.sh
# Every 15 minutes during business hours
*/15 9-17 * * 1-5 /path/to/frequent-job.sh
# First day of every month at midnight
0 0 1 * * /path/to/monthly-job.sh
Modern Alternatives to System Cron
While system cron works, modern deployment patterns call for better solutions:
Docker-based scheduling:
# docker-compose.yml
services:
app:
image: myapp:latest
# ... other config
scheduler:
image: myapp:latest
command: >
sh -c "echo '0 2 * * * /app/cleanup.sh' | crontab - && crond -f"
volumes:
- ./logs:/var/log/myapp
Kubernetes CronJobs:
apiVersion: batch/v1
kind: CronJob
metadata:
name: cleanup-job
spec:
schedule: "0 2 * * *"
jobTemplate:
spec:
template:
spec:
containers:
- name: cleanup
image: myapp:cleanup
command: ["/app/cleanup.sh"]
restartPolicy: OnFailure
Environment-Specific Gotchas
PATH Problems
Cron runs with a minimal environment. Always use full paths or set PATH explicitly:
#!/bin/bash
# Set PATH explicitly
PATH=/usr/local/bin:/usr/bin:/bin
export PATH
# Or use full paths
/usr/bin/node /app/scheduled-task.js
/usr/local/bin/python3 /app/cleanup.py
Environment Variables
Load your app's environment in cron jobs:
#!/bin/bash
# Load environment variables
source /app/.env
# Or for more complex setups
if [ -f /app/config/production.env ]; then
export $(cat /app/config/production.env | xargs)
fi
# Now run your application code
/app/bin/scheduled-task
Timezone Considerations
Cron uses the system timezone. For apps with global users, be explicit:
# Run at 2 AM UTC regardless of server timezone
0 2 * * * TZ=UTC /path/to/script.sh
# Or set timezone for all cron jobs
TZ=UTC
0 2 * * * /path/to/daily-job.sh
0 6 * * 1 /path/to/weekly-job.sh
Monitoring and Alerting
Dead Man's Switch Pattern
Set up monitoring that expects your cron jobs to "check in":
import requests
from datetime import datetime
def heartbeat_check_in(job_name):
"""Notify monitoring system that job completed successfully"""
try:
requests.post(
f"https://hc-ping.com/your-unique-id/{job_name}",
timeout=10
)
except Exception as e:
print(f"Failed to send heartbeat: {e}")
def cleanup_old_data():
# Your cleanup logic
deleted_count = perform_cleanup()
print(f"Deleted {deleted_count} old records")
# Signal successful completion
heartbeat_check_in("cleanup")
if __name__ == "__main__":
cleanup_old_data()
Structured Logging for Better Debugging
import json
import logging
from datetime import datetime
# Configure structured logging
logging.basicConfig(
level=logging.INFO,
format='%(message)s'
)
def log_structured(level, message, **kwargs):
log_entry = {
'timestamp': datetime.utcnow().isoformat(),
'level': level,
'message': message,
'job': 'data_cleanup',
**kwargs
}
print(json.dumps(log_entry))
def cleanup_job():
log_structured('INFO', 'Starting cleanup job')
try:
deleted = cleanup_old_records()
log_structured('INFO', 'Cleanup completed',
records_deleted=deleted)
except Exception as e:
log_structured('ERROR', 'Cleanup failed',
error=str(e))
raise
Testing Cron Jobs Locally
Don't wait for production to test your scheduled tasks:
# Test your cron script directly
./scripts/cleanup.sh
# Simulate the cron environment
env -i PATH=/usr/bin:/bin ./scripts/cleanup.sh
# Test with different timezones
TZ=America/New_York ./scripts/cleanup.sh
TZ=Europe/London ./scripts/cleanup.sh
The Modern Deployment Reality
If you're using modern deployment platforms, traditional cron might not be the best fit. Many platforms offer their own scheduling solutions:
- Vercel: Vercel Cron for serverless functions
- Heroku: Heroku Scheduler add-on
- Railway: Built-in cron jobs
- Render: Cron jobs as a service
These managed solutions handle scaling, monitoring, and reliability for you - perfect for vibe coders who want to focus on building, not babysitting infrastructure.
Key Takeaways
- Never let cron jobs fail silently - always include comprehensive logging and alerting
- Make jobs idempotent - they should be safe to run multiple times
- Use lock files to prevent overlapping executions
- Set explicit environments - PATH, timezone, and environment variables
- Monitor with heartbeats - know when jobs don't run
- Test locally first - don't debug cron jobs in production
Scheduled tasks might seem boring compared to building shiny new features, but they're critical for production apps. Set them up right the first time, and you'll sleep better knowing your app is maintaining itself properly.
Need help setting up robust cron jobs and monitoring for your AI-built app? That's exactly what we handle at DeployMyVibe - so you can focus on shipping features instead of debugging why your cleanup job failed at 3 AM.
Alex Hackney
DeployMyVibe