How to Automate Start/Stop Scheduling for Non-Production AWS Resources Using EventBridge Scheduler and SSM Automation
This post describes how to build a fully serverless scheduling solution that starts and stops non-production AWS resources on a cron schedule. The solution uses Amazon EventBridge Scheduler and AWS Systems Manager (SSM) Automation to achieve up to 70% cost savings with zero custom infrastructure to maintain.
The solution supports EC2, RDS/Aurora, Redshift, EKS Node Groups, and OpenSearch. It uses tag-based targeting, deploys as a reusable Terraform module, and costs $0/month to operate.
Problem
Non-production environments (development, testing, staging) often run 24/7. Teams only use these resources during business hours — approximately 50 of the 168 hours per week. The remaining 118 hours represent wasted spend.
For a fleet of 50 non-production instances, this waste amounts to approximately $29,000 per year.
| Schedule | Hours Running/Week | Monthly Cost | Savings | Annual Savings |
|---|---|---|---|---|
| 24/7 (no scheduling) | 168h | $3,500 | 0% | $0 |
| Stop nights only (19:00–07:00) | 70h | $1,470 | ~58% | $24,360 |
| Stop nights + weekends | 50h | $1,050 | ~70% | $29,400 |
| Nights + weekends + holidays | ~47h | $980 | ~72% | $30,240 |
The challenge: implement scheduling that is low-maintenance, supports multiple resource types, handles overrides, and deploys consistently across accounts.
Solution
We use Amazon EventBridge Scheduler to define cron-based start/stop times, which directly invoke SSM Automation Runbooks. Each runbook discovers tagged resources and executes the appropriate start or stop API calls.
Architecture
EventBridge Scheduler (cron + timezone)
│
▼
SSM Automation Runbook (per resource type)
│
├──▶ Discover resources by tag (Schedule:office-hours)
├──▶ Check override tag (KeepRunning:true → skip)
└──▶ Execute start/stop API call
Why this approach
We evaluated four alternatives before selecting EventBridge Scheduler + SSM Automation:
| Criteria | EventBridge + SSM | AWS Instance Scheduler | Custom Lambda | SSM Quick Setup |
|---|---|---|---|---|
| Setup complexity | Low | Medium | High | Low |
| Resources supported | Any AWS API | EC2/RDS/ASG | Any | EC2/RDS |
| Maintenance | None | Low | High | None |
| Monthly cost | Free | ~$5 | ~$1 | Free |
| Multi-account | Per-account deploy | Hub-spoke | Custom code | Org-native |
We selected EventBridge + SSM because it supports all five target resource types with zero infrastructure to maintain, provides native timezone handling, and includes built-in retry policies and execution logging.
How it works
Step 1: Tag your resources
Resources opt into scheduling with a single tag:
Key: Schedule
Value: office-hours
To exclude a resource from being stopped (for example, during a deployment):
Key: KeepRunning
Value: true
Example:
aws ec2 create-tags --resources i-0123456789abcdef0 \ --tags Key=Schedule,Value=office-hours aws rds add-tags-to-resource \ --resource-name arn:aws:rds:af-south-1:123456789012:db:mydb \ --tags Key=Schedule,Value=office-hours
Step 2: Deploy the Terraform module
The solution is packaged as a Terraform module with the following structure:
terraform-resource-scheduler/
├── versions.tf # Terraform >= 1.5, AWS ~> 5.0
├── variables.tf # All inputs with validation blocks
├── main.tf # Provider, locals, schedule group
├── iam.tf # Two IAM roles + policies
├── ssm_documents.tf # 5 SSM Automation runbooks
├── schedules.tf # 10 EventBridge schedules (start + stop × 5)
├── sns.tf # Optional email notifications
├── outputs.tf # Role ARNs, document names, tagging info
└── terraform.tfvars.example # Copy and customize
Deploy:
cd terraform-resource-scheduler cp terraform.tfvars.example terraform.tfvars # Edit with your values terraform init terraform plan terraform apply
Key parameters:
| Parameter | Default | Description |
|---|---|---|
start_schedule_expression | cron(0 7 ? * MON-FRI *) | When to start resources |
stop_schedule_expression | cron(0 19 ? * MON-FRI *) | When to stop resources |
schedule_timezone | Africa/Johannesburg | IANA timezone |
schedule_tag_key | Schedule | Tag key for targeting |
schedule_tag_value | office-hours | Tag value for targeting |
enable_ec2 / rds / redshift / eks / opensearch | true | Toggle each resource type |
notification_email | "" | Email for SNS alerts (empty disables) |
Step 3: Verify execution
After deployment, monitor executions:
aws ssm describe-automation-executions \ --filters Key=DocumentNamePrefix,Values=resource-scheduler aws scheduler list-schedules \ --group-name resource-scheduler-non-prod
Implementation details
SSM Automation Runbooks
Each resource type has a dedicated runbook. The EC2 runbook uses an aws:executeScript step with Python:
import boto3 def handler(event, context): ec2 = boto3.client('ec2') action = event['Action'] tag_key = event['TagKey'] tag_value = event['TagValue'] override_key = event['OverrideTagKey'] state_filter = 'running' if action == 'Stop' else 'stopped' response = ec2.describe_instances( Filters=[ {'Name': f'tag:{tag_key}', 'Values': [tag_value]}, {'Name': 'instance-state-name', 'Values': [state_filter]} ] ) instance_ids = [] for reservation in response['Reservations']: for instance in reservation['Instances']: tags = {t['Key']: t['Value'] for t in instance.get('Tags', [])} if action == 'Stop' and tags.get(override_key, '').lower() == 'true': continue instance_ids.append(instance['InstanceId']) if not instance_ids: return {'status': 'No instances to process', 'count': 0} if action == 'Stop': ec2.stop_instances(InstanceIds=instance_ids) else: ec2.start_instances(InstanceIds=instance_ids) return {'status': f'{action} executed', 'count': len(instance_ids)}
The RDS runbook handles both standalone instances and Aurora clusters. The Redshift runbook uses PauseCluster and ResumeCluster. The OpenSearch runbook uses UpdateDomainConfig (requires engine version >= 2.13).
The EKS runbook scales the node group rather than stopping instances:
mainSteps: - name: ScaleNodeGroup action: aws:executeAwsApi inputs: Service: eks Api: UpdateNodegroupConfig clusterName: '{{ ClusterName }}' nodegroupName: '{{ NodeGroupName }}' scalingConfig: desiredSize: 0 minSize: 0 maxSize: 5
The EKS control plane remains running (minimal cost). Nodes are terminated and recreated on scale-up.
EventBridge Scheduler configuration
resource "aws_scheduler_schedule" "ec2_start" { name = "resource-scheduler-ec2-start" schedule_expression = "cron(0 7 ? * MON-FRI *)" schedule_expression_timezone = "Africa/Johannesburg" flexible_time_window { mode = "OFF" } target { arn = "arn:aws:ssm:${region}:${account_id}:automation-definition/${ssm_doc}" role_arn = aws_iam_role.scheduler_execution.arn input = jsonencode({ Action = ["Start"] TagKey = ["Schedule"] TagValue = ["office-hours"] }) retry_policy { maximum_retry_attempts = 2 maximum_event_age_in_seconds = 3600 } } }
IAM roles
The solution uses two roles following the principle of least privilege:
Scheduler Execution Role (assumed by EventBridge Scheduler):
ssm:StartAutomationExecution— invoke SSM Automation documentsiam:PassRole— restricted to the SSM automation role ARN
SSM Automation Role (assumed during runbook execution):
- Start/stop permissions for each resource type (EC2, RDS, Redshift, EKS, OpenSearch)
tag:GetResourcesfor tag-based discoverysns:Publishfor notifications
No wildcard actions. Role separation ensures the scheduler cannot directly call resource APIs.
Design considerations
Timezone handling
Configure schedules in your local timezone. EventBridge Scheduler supports IANA timezones natively and handles DST transitions. Using UTC leads to schedules that shift by an hour during daylight saving changes.
Override mechanism
Add the KeepRunning:true tag to any resource to exclude it from stop actions. The runbook checks this tag before executing. Remove the tag to resume scheduling.
For time-limited overrides, use KeepRunningUntil:YYYY-MM-DD (requires separate cleanup logic or a Config rule).
Graceful shutdown
- EC2: Receives a standard OS-level stop signal. Applications should handle SIGTERM.
- EKS: Pod disruption budgets are respected during node termination.
- RDS: Connections are terminated gracefully during stop.
For critical workloads, add an SSM Run Command step before stop to flush caches or drain connections.
Tag enforcement
Pair the scheduler with an AWS Config rule to ensure all non-production resources carry the Schedule tag:
resource "aws_config_config_rule" "require_schedule_tag" { name = "require-schedule-tag-nonprod" source { owner = "AWS" source_identifier = "REQUIRED_TAGS" } input_parameters = jsonencode({ tag1Key = "Schedule" tag1Value = "office-hours" }) }
Rollout plan
| Phase | Timeline | Scope |
|---|---|---|
| Pilot | Week 1–2 | 5–10 non-critical EC2 instances |
| Expand | Week 3–4 | All EC2 and RDS in one account |
| Full services | Week 5–6 | Add Redshift, EKS, OpenSearch |
| Scale | Week 7+ | All non-prod accounts; enable tag enforcement |
Success criteria: zero unintended stoppages, all scheduled resources start on time, cost reduction visible in Cost Explorer within one billing cycle.
Conclusion
This solution provides zero-cost, zero-maintenance scheduling for five AWS resource types. It deploys in under 15 minutes via Terraform, targets resources through tags, and delivers approximately $29,000 in annual savings for a typical 50-instance non-production fleet.
The combination of EventBridge Scheduler and SSM Automation eliminates the operational overhead of Lambda-based schedulers while providing native timezone support, built-in retry, and complete execution audit trails.
Questions about this approach? Leave a comment below or reach out on the AWS re:Post community.
Relevant content
- Accepted Answer
asked 2 years ago
AWS OFFICIALUpdated 6 months ago
AWS OFFICIALUpdated a month ago
AWS OFFICIALUpdated 2 years ago