Kill Your EC2 Cron Jobs: Serverless Scheduling with Lambda, EventBridge & Terraform
That EC2 instance running your cron jobs costs $45/year, needs OS patches, and is a single point of failure. Here's the complete Terraform-based migration to Lambda + EventBridge — with 99.8% cost savings and zero server management.
The EC2 Cron Problem
You've got a cron job. Maybe it cleans up old database records every night, generates a daily report at 6am, or syncs data between two systems every 15 minutes. So you spun up an EC2 instance, dropped a crontab -e entry, and moved on. It works — until it doesn't.
The instance needs an OS patch. You forget to restart the cron service after the reboot. The instance goes into an impaired state at 2am and nobody notices until the Monday morning report is missing. You need to add another scheduled job and wonder if you need to upgrade the instance type.
Every EC2 cron job is a small operational tax that compounds over time. The serverless alternative eliminates every one of these problems — and it costs almost nothing.
Before vs After: Architecture Comparison
How It Works
Three AWS primitives wired together — that's the entire solution. EventBridge acts as the scheduler, firing on a schedule expression you define. When the rule triggers, it invokes your Lambda function, which runs your task in a fully managed execution environment. An IAM Role grants Lambda the minimum permissions it needs. CloudWatch Logs capture every invocation automatically, with zero extra configuration.
aws_cloudwatch_event_rule, aws_cloudwatch_event_target) still use the old naming but they create EventBridge resources. Both names work — the underlying service is the same.
Project Structure
The entire solution lives in four files. Lambda code in a dedicated directory, Terraform configs at the root:
├── main.tf # all AWS resources
├── variables.tf # configurable inputs
├── outputs.tf # Lambda ARN, rule name
└── lambda/
└── handler.js # your scheduled task logic
Step 1 — Write Your Lambda Handler
Your Lambda function replaces what used to be a shell script in a crontab. The handler receives an EventBridge event object (which you can mostly ignore for cron use cases) and runs your task. The key difference from an EC2 script: every execution is isolated, stateless, and logged automatically.
// lambda/handler.js
exports.handler = async (event) => {
const startTime = Date.now();
console.log('Scheduled task started', {
triggeredAt: new Date().toISOString(),
source: event.source, // "aws.events"
detailType: event['detail-type'] // "Scheduled Event"
});
try {
await runTask();
const duration = Date.now() - startTime;
console.log('Task completed', { durationMs: duration });
return {
statusCode: 200,
body: JSON.stringify({
message: 'Success',
durationMs: duration,
timestamp: new Date().toISOString()
})
};
} catch (err) {
// Re-throw so Lambda marks this invocation as an error
// and CloudWatch records it — essential for alerting
console.error('Task failed', { error: err.message, stack: err.stack });
throw err;
}
};
async function runTask() {
// ── Your task logic goes here ──────────────────────────
// Examples:
// • Clean up records older than 30 days from DynamoDB
// • Generate and email a daily report
// • Sync data between two APIs
// • Expire stale user sessions
// • Trigger a downstream Step Functions workflow
// ──────────────────────────────────────────────────────
console.log('Running scheduled task...');
}
Step 2 — Terraform: Build All Five Resources
Five Terraform resources wire the whole thing together. Each one has a specific job and they must all be present — missing even the Lambda permission resource will result in EventBridge silently failing to invoke your function.
lambda.amazonaws.com only.AWSLambdaBasicExecutionRole policy — grants only the ability to write logs to CloudWatch. Add extra policies here if your function accesses S3, DynamoDB, etc.rate() expression or a cron() expression. The rule stays enabled/disabled independently of the Lambda function.aws_lambda_permission resource granting EventBridge invoke rights.# ── main.tf ────────────────────────────────────────────────
# 1. IAM Role — Lambda assumes this when executing
resource "aws_iam_role" "lambda_role" {
name = "cron-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
}
# 2. Attach basic execution policy (CloudWatch Logs access)
resource "aws_iam_role_policy_attachment" "lambda_logs" {
role = aws_iam_role.lambda_role.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
# Auto-zip the handler — Terraform recreates on file changes
data "archive_file" "lambda_zip" {
type = "zip"
source_file = "${path.module}/lambda/handler.js"
output_path = "${path.module}/lambda/function.zip"
}
# 3. Lambda Function
resource "aws_lambda_function" "cron_job" {
filename = data.archive_file.lambda_zip.output_path
function_name = "cron-job-${var.environment}"
role = aws_iam_role.lambda_role.arn
handler = "handler.handler" # file.exportedFunction
runtime = "nodejs20.x"
source_code_hash = data.archive_file.lambda_zip.output_base64sha256
timeout = 300 # 5 minutes — adjust to your task duration
memory_size = 256 # MB — more memory = more vCPU allocation
environment {
variables = {
ENVIRONMENT = var.environment
# Never hardcode secrets — use SSM or Secrets Manager ARNs
DB_SECRET_ARN = var.db_secret_arn
}
}
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
# 4. EventBridge Rule — defines the schedule
resource "aws_cloudwatch_event_rule" "schedule" {
name = "cron-job-schedule"
description = "Triggers the cron Lambda on schedule"
schedule_expression = var.schedule_expression # e.g. "rate(1 hour)"
state = "ENABLED"
}
# Connect the rule to the Lambda function
resource "aws_cloudwatch_event_target" "lambda_target" {
rule = aws_cloudwatch_event_rule.schedule.name
target_id = "cron-lambda"
arn = aws_lambda_function.cron_job.arn
}
# 5. Lambda Permission — REQUIRED — without this EventBridge is blocked
resource "aws_lambda_permission" "allow_eventbridge" {
statement_id = "AllowExecutionFromEventBridge"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.cron_job.function_name
principal = "events.amazonaws.com"
source_arn = aws_cloudwatch_event_rule.schedule.arn
}
# ── variables.tf ───────────────────────────────────────────
variable "environment" {
description = "Environment name (prod, staging, dev)"
type = string
default = "prod"
}
variable "schedule_expression" {
description = "EventBridge schedule — rate() or cron() expression"
type = string
default = "rate(1 hour)"
}
variable "db_secret_arn" {
description = "ARN of the Secrets Manager secret for DB credentials"
type = string
default = ""
}
# ── outputs.tf ─────────────────────────────────────────────
output "lambda_function_name" {
value = aws_lambda_function.cron_job.function_name
}
output "lambda_function_arn" {
value = aws_lambda_function.cron_job.arn
}
output "eventbridge_rule_arn" {
value = aws_cloudwatch_event_rule.schedule.arn
}
Step 3 — Schedule Expression Reference
EventBridge supports two expression formats. rate() is simple and readable. cron() gives you precise control for specific times of day, days of the week, or month-specific schedules. Note the AWS cron format has 6 fields and requires either day-of-month or day-of-week to be ? — you can't specify both.
| Use Case | Expression | Notes |
|---|---|---|
| Every 5 minutes | rate(5 minutes) | Simplest rate expression |
| Every hour | rate(1 hour) | Singular unit — not "hours" |
| Daily at 2am UTC | cron(0 2 * * ? *) | ? in day-of-week = don't care |
| Every Mon at 9am UTC | cron(0 9 ? * MON *) | ? in day-of-month, MON in day-of-week |
| 1st of month at midnight | cron(0 0 1 * ? *) | Monthly billing reports etc. |
| Weekdays at 8am UTC | cron(0 8 ? * MON-FRI *) | Skips Sat/Sun automatically |
Step 4 — Deploy with Terraform
Standard three-step Terraform workflow. The plan output will show all five resources being created — review it before applying, especially on subsequent deployments when updating schedule expressions or Lambda code.
# Initialise providers and download modules
terraform init
# Preview what will be created — review all 5 resources
terraform plan
# Apply — Terraform handles dependency ordering automatically
# IAM role → Lambda → EventBridge rule → permission
terraform apply
Terraform resolves the dependency graph automatically. The IAM role must exist before Lambda, the Lambda function before the EventBridge target, and both before the Lambda permission resource. You don't specify this order — Terraform infers it from the resource references.
Step 5 — Verify It's Running
Three places to confirm your cron Lambda is firing correctly after deployment:
# 1. Check CloudWatch log group for invocation logs
aws logs describe-log-groups \
--log-group-name-prefix "/aws/lambda/cron-job"
# Tail logs in real time
aws logs tail "/aws/lambda/cron-job-prod" --follow
# 2. Check last invocation time directly on the function
aws lambda get-function \
--function-name cron-job-prod \
--query 'Configuration.{LastModified:LastModified}'
# 3. Verify the EventBridge rule is ENABLED
aws events describe-rule --name cron-job-schedule \
--query '{State:State, Schedule:ScheduleExpression}'
{} as the test event — your handler should behave identically to a real EventBridge trigger.
Production Best Practices
Idempotency
EventBridge guarantees at-least-once delivery — your Lambda might fire twice for the same schedule window during rare edge cases. Design your task so running it twice produces the same result. A database cleanup job should check a timestamp before deleting; a report generator should check if today's report already exists.
Secrets Management
Never put credentials in environment variables as plaintext. Reference Secrets Manager ARNs instead and fetch them at runtime:
# Terraform — pass only the ARN, not the secret value
resource "aws_lambda_function" "cron_job" {
# ... existing config ...
environment {
variables = {
DB_SECRET_ARN = "arn:aws:secretsmanager:us-east-1:123456789:secret:prod/db"
}
}
}
# Don't forget to grant Secrets Manager access in the IAM policy
resource "aws_iam_role_policy" "lambda_secrets" {
name = "cron-lambda-secrets"
role = aws_iam_role.lambda_role.id
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = ["secretsmanager:GetSecretValue"]
Resource = "arn:aws:secretsmanager:*:*:secret:prod/*"
}]
})
}
// In your Lambda — fetch secret at runtime
const { SecretsManagerClient, GetSecretValueCommand } = require("@aws-sdk/client-secrets-manager");
const client = new SecretsManagerClient({ region: "us-east-1" });
let cachedSecret; // cache across warm invocations
async function getDbCredentials() {
if (cachedSecret) return cachedSecret;
const response = await client.send(new GetSecretValueCommand({
SecretId: process.env.DB_SECRET_ARN
}));
cachedSecret = JSON.parse(response.SecretString);
return cachedSecret;
}
Add a CloudWatch Alarm for Failures
A cron job that silently fails is worse than one that crashes loudly. Wire up an alarm on Lambda errors so you know within minutes if your scheduled task stops working:
resource "aws_cloudwatch_metric_alarm" "cron_errors" {
alarm_name = "cron-job-failures"
alarm_description = "Scheduled Lambda function is failing"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "Errors"
namespace = "AWS/Lambda"
period = 300
statistic = "Sum"
threshold = 0
treat_missing_data = "notBreaching"
dimensions = {
FunctionName = aws_lambda_function.cron_job.function_name
}
alarm_actions = [aws_sns_topic.alerts.arn]
}
The Cost Reality
Here's the actual numbers for a cron job that runs every hour and executes for 5 seconds, compared against the cheapest EC2 option:
Why This Beats EC2 Cron in Every Dimension
When to Keep EC2 Cron Jobs
Lambda isn't always the right answer. Stick with EC2 cron (or migrate to ECS Scheduled Tasks instead) when:
- Your task runs longer than 15 minutes — Lambda's hard maximum timeout
- You need more than 10GB of memory — Lambda's ceiling
- Your task requires a specific OS, GPU, or custom runtime that Lambda doesn't support
- You need persistent local disk storage beyond Lambda's 512MB–10GB ephemeral /tmp
For everything else — database cleanup, report generation, data sync, notification sending, cache warming — Lambda + EventBridge is the right default.