A development instance that runs nights and weekends is idle for about two thirds of the hours you pay for. Shutting it down on a schedule is twenty lines of code, and unlike the equivalent trick for RDS there is no seven-day limit to work around — a stopped EC2 instance stays stopped.
What stopping actually saves
You stop paying for instance hours immediately. You keep paying for:
- EBS volumes. The root volume still exists and still bills at the full per-GB rate. This is the bulk of the remaining cost
- Elastic IPs, if one is allocated — and an idle Elastic IP is charged whether or not it is attached
- Snapshots and AMIs you have taken
Anything on an instance store volume is destroyed on stop, not preserved. That is a property of the storage type, not a bug, and it is worth knowing before you stop something with scratch data on it.
The handler
import boto3
ec2 = boto3.client("ec2")
TAG_KEY = "Schedule"
TAG_VALUE = "nightly-stop"
def lambda_handler(event, context):
response = ec2.describe_instances(
Filters=[
{"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]},
{"Name": "instance-state-name", "Values": ["running"]},
]
)
ids = [
instance["InstanceId"]
for reservation in response["Reservations"]
for instance in reservation["Instances"]
]
if not ids:
print("nothing tagged and running")
return {"stopped": []}
ec2.stop_instances(InstanceIds=ids)
print(f"stopping: {ids}")
return {"stopped": ids}Filtering server-side rather than fetching everything and matching in Python is the difference
between one API call and a paginated sweep of the account. The state filter matters too:
stop_instances on an already-stopped instance is harmless, but including them makes
the log useless for telling whether the schedule did anything.
The bug worth being afraid of
An empty filter list is not a filter that matches nothing. It is no filter at all.
If you build filters conditionally and the tag value ends up unset, you can produce
describe_instances(Filters=[]) — which returns every instance in the region.
The next line then stops all of them. The code looks correct, the tests pass with a tag present,
and the failure only appears the first time a config value is missing.
So guard the result, not the input:
if not TAG_VALUE:
raise RuntimeError("TAG_VALUE is empty — refusing to run an unfiltered stop")And do the same thing in IAM, where a bug cannot reach at all.
The IAM policy
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "FindInstances",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*"
},
{
"Sid": "StopOnlyTaggedInstances",
"Effect": "Allow",
"Action": "ec2:StopInstances",
"Resource": "*",
"Condition": {
"StringEquals": { "aws:ResourceTag/Schedule": "nightly-stop" }
}
}
]
}ec2:DescribeInstances cannot be scoped to a resource — the describe actions do not
support resource-level permissions, so it is "*" and that is fine, because reading is
not what you are worried about. ec2:StopInstances does support them, and the condition
is what makes the unfiltered-stop bug above unable to touch production.
The schedule
aws scheduler create-schedule \
--name ec2-nightly-stop \
--schedule-expression "cron(0 19 ? * MON-FRI *)" \
--schedule-expression-timezone "America/Denver" \
--flexible-time-window "Mode=OFF" \
--target '{"Arn":"arn:aws:lambda:us-west-2:111122223333:function:ec2-nightly-stop","RoleArn":"arn:aws:iam::111122223333:role/scheduler-invoke"}'Stopping on weekday evenings only is deliberate: pair it with a weekday-morning start and the instance stays down all weekend, which is the largest single saving available.
Stopping is not killing
stop_instances asks the guest operating system to shut down. Anything running gets
the usual signals and a chance to finish, which means a long build or a batch job may still be
mid-flight when the schedule fires — and a database on that instance may be mid-write.
If that is a real risk, the answer is not to skip the schedule, it is to make the instance safe to stop at any moment: write work to durable storage rather than to local disk, and run anything long under something that can resume. An instance you are afraid to stop is one you cannot patch either.
Two escape hatches worth knowing. Tagging is the intended way to opt out, so removing the tag takes an instance off the schedule with no code change. And for the case where a stop must never happen, EC2 has explicit stop protection:
aws ec2 modify-instance-attribute --instance-id i-0abc123def456 --disable-api-stopWith that set, the API call fails rather than succeeding quietly, which is the behaviour you want for anything that matters.
Checking it worked
aws ec2 describe-instances \
--filters "Name=tag:Schedule,Values=nightly-stop" \
--query "Reservations[].Instances[].[InstanceId,State.Name]" \
--output tableThe transition is running → stopping → stopped, and it
takes a minute or two because the operating system is asked to shut down cleanly first. If an
instance sits in stopping for a long time, the guest is refusing to halt; a forced
stop is available and is the equivalent of pulling the power.
Is it worth it?
Do the arithmetic before you build it. A week is 168 hours; running 7:45am to 7pm on weekdays is 56.25 hours, so the instance is up about a third of the time and you save roughly two thirds of the instance-hour line.
How much of the bill that actually is depends on the volume attached. A t3.large with a 30 GB gp3 root volume is overwhelmingly compute, so the saving is close to the headline. The same instance with a 2 TB volume is mostly storage, and stopping it saves far less than you would guess. Group Cost Explorer by usage type and look before you build.
The companion post covers starting them again, which is where the scheduling detail lives.