← back to blog
serverless Featured · Dec 1, 2025 · 12 min read

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.

SJ
Sabin Joshi
DevOps Engineer
#aws #lambda #eventbridge #serverless #terraform #devops #cron #iac

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.

$0.01
monthly Lambda cost (hourly cron)
$45
cheapest EC2 alternative/year
99.8%
cost reduction
0
servers to manage

Before vs After: Architecture Comparison

EC2 Cron vs Serverless Lambda — Architecture Comparison
❌ BEFORE — EC2 Cron Job Approach EC2 Instance (running 24/7) crontab -e * * * * * ./job.sh OS / patches security + updates ✗ single point of failure ✗ paying 24/7 for minutes of work ✗ manual scaling + monitoring System Clock ✅ AFTER — Lambda + EventBridge EventBridge rate(1 hour) cron(0 2 * * ? *) λ Lambda executes task auto-scales CloudWatch logs + metrics IAM Role least-privilege ✓ pay per millisecond of execution ✓ multi-AZ HA out of the box ✓ zero server management ever 🏗 Entire right side managed as Terraform IaC

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.

ℹ️ EventBridge was previously known as CloudWatch Events. The Terraform resources (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:

terraform-lambda-cron/
├── 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...');
}
⚠️ Always re-throw errors in your handler. If you catch an error and return a 200, Lambda marks the invocation as successful — CloudWatch error metrics won't fire and your alarms won't trigger. Let the error propagate so failures are visible.

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.

1
IAM Role — Lambda's Identity
Creates a role the Lambda service can assume. The trust policy restricts who can use this role to lambda.amazonaws.com only.
2
IAM Policy Attachment — CloudWatch Permissions
Attaches AWS's managed AWSLambdaBasicExecutionRole policy — grants only the ability to write logs to CloudWatch. Add extra policies here if your function accesses S3, DynamoDB, etc.
3
Lambda Function — The Worker
Packages your handler.js into a ZIP (Terraform handles this automatically) and creates the function with runtime, timeout, memory, and environment variables.
4
EventBridge Rule — The Scheduler
Defines when to trigger — using either a rate() expression or a cron() expression. The rule stays enabled/disabled independently of the Lambda function.
5
Lambda Permission — The Handshake
This is the one people forget. Even though the EventBridge rule points to Lambda, Lambda will refuse the call without an explicit 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.

EventBridge Cron Expression Anatomy
cron( 0 9 * * MON * ) Minutes 0–59 Hours 0–23 (UTC) Day-of-Month 1–31 or * Month 1–12 or * Day-of-Week MON–SUN or ? Year optional ↑ This expression fires every Monday at 9:00 AM UTC Day-of-month is * and day-of-week is MON — only one of these can be set per AWS rules
Use CaseExpressionNotes
Every 5 minutesrate(5 minutes)Simplest rate expression
Every hourrate(1 hour)Singular unit — not "hours"
Daily at 2am UTCcron(0 2 * * ? *)? in day-of-week = don't care
Every Mon at 9am UTCcron(0 9 ? * MON *)? in day-of-month, MON in day-of-week
1st of month at midnightcron(0 0 1 * ? *)Monthly billing reports etc.
Weekdays at 8am UTCcron(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}'
💡 Use the Lambda console's Test tab to manually trigger an invocation before waiting for the schedule. Pass an empty JSON object {} 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:

❌ EC2 Cron Job
$45.60
per year · t3.nano always-on
Instance runs 8,760 hours/year
Paying 99.94% idle time
OS patching overhead
Manual failure recovery
Single AZ — no HA
✅ Lambda + EventBridge
$0.10
per year · 720 executions/month
720 invocations/month × 5s = 3,600s compute
Cost: ~$0.008/month at 128MB
Zero server management
Automatic multi-AZ HA
Built-in CloudWatch logging
💡 The Lambda free tier includes 1M free requests and 400,000 GB-seconds of compute per month — permanently, not just the first year. Most cron jobs will run entirely free forever within the free tier limits.

Why This Beats EC2 Cron in Every Dimension

💰
Pay Per Execution
Billed in 1ms increments for actual compute time. A 5-second job running hourly costs fractions of a cent per month — not $45/year for an idle VM.
🛡️
Built-in High Availability
Lambda runs across multiple AZs automatically. No single EC2 instance to fail, no recovery runbook, no 2am page because a VM went into an impaired state.
🔧
Zero Server Management
No OS to patch, no security updates, no SSH keys, no instance monitoring. AWS handles all of it. You focus exclusively on your task logic.
📈
Independent Scaling
Need 10 scheduled tasks? Create 10 Lambda functions and 10 EventBridge rules. Each scales independently. No instance resizing, no capacity planning.
📊
Free Observability
Every invocation is automatically logged to CloudWatch — duration, memory used, errors, cold starts. No CloudWatch agent setup, no log shipping config.
🏗️
Infrastructure as Code
Every resource is in Terraform. Version controlled, peer-reviewed, reproducible across environments. Your cron jobs are now as auditable as your application code.

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.

💡 Migrating? Start with your least critical cron job — something whose failure has low business impact. Run it in parallel with the EC2 version for a week, validate the logs, then cut over. Don't try to migrate all cron jobs at once.