The 36% problem
A dev VM used from 8 to 8, five days a week, works 60 hours. The cloud bills 168. That is about 36% utilization (60 divided by 168), and the other 64% is paid for nobody (source).
Multiply that by every dev and QA instance and idle time becomes a real line item. This guide takes you from "what would I save?" to a tagged, scheduled stop/start job that alerts you when it fails. You get a worksheet, an unexecuted example script, a verification step and a pre-flight checklist.
Step 1: Price your own idle hours
The formula is short: running VMs × hourly rate × off hours. Use your actual running inventory, not a generic calculator. List every running instance, look up its on-demand or effective hourly rate in your billing data, and add the rates together.
| Input | Where it comes from | |---|---| | Running dev/QA VMs | Your provider's instance list, filtered to running | | Combined hourly rate | Sum of each VM's hourly price | | Off hours per night | Hours between stop and start, for example 12 | | Off hours per week | 168 minus hours in use (108 for the 8-to-8, five-day pattern) |
The 108 is not 12 × 5. Four weeknights give 48 off hours, and Friday 8 p.m. to Monday 8 a.m. adds 60.
Then multiply:
- Per night: combined hourly rate × off hours per night
- Per week: combined hourly rate × off hours per week
- Per year: per-night figure × 365, or per-week figure × 52
What the demo numbers look like
The dev.to post reports one demo organization: 16 running VMs, 12 hours a night, $1.51 an hour, $18.16 a night, $127 a week and $6,627 a year. These figures come from a single demo. They are not benchmarks, and your account will differ.
The arithmetic still helps. $1.51 × 12 is about $18.12, close to the stated $18.16, so $1.51 looks like the combined hourly rate of all 16 VMs, rounded. That is an inference from the numbers, not something the post states. Seven nights of $18.16 give $127, and 365 nights give about $6,628, which is consistent with the post's weekly and yearly figures.
As an illustration only, applying the same combined rate to the 108 weekly off hours gives roughly $163 a week. The demo's $127 counts seven nights of 12 hours, so a schedule that also covers the full weekend saves more.
What to leave out
Exclude anything that bills around the clock whether or not you stop VMs. The post names databases, load balancers and NAT gateways. Stopping compute does not touch them, so counting them inflates your estimate.
The reverse matters too. A stopped VM stops compute charges, but its attached disks keep billing. The demo in the post lists 5 disks of stopped VMs as a separate cost. Review that cost on its own.
Step 2: Decide what is safe to stop
Select by an explicit tag that a human applied on purpose, never by name or by guesswork. A workable scheme uses two tags: Environment with a value like dev or qa, and Schedule with a value like office-hours.
The script acts only on instances that carry both. Production never matches because it has neither. Anything stateful that cannot survive a restart, such as a VM holding data only in memory or a build agent mid-job, simply does not get the Schedule tag.
On AWS, tag keys and values are case-sensitive, so Office-Hours and office-hours are different. Pick one spelling and enforce it. A typo here is the quietest way to leave a VM running.
Step 3: Write the stop/start job (unexecuted example)
The code below has not been run. Review it and test it in a sandbox account before pointing it at anything real. It assumes Python 3 and boto3. The dev.to post gives no version requirements, so pin the versions you test with in a requirements.txt. Credentials come from the usual AWS environment or role. We cover related ground in how to move from quickstart to production (checklist).
## UNEXECUTED EXAMPLE: test in a sandbox account first.
## Needs: Python 3, boto3 (pin your tested version).
## IAM: ec2:DescribeInstances, ec2:StopInstances, ec2:StartInstances, sns:Publish
import os
import boto3
REGION = os.environ.get('AWS_REGION', 'eu-west-1')
TOPIC_ARN = os.environ['ALERT_TOPIC_ARN']
ACTION = os.environ.get('ACTION', 'stop') # 'stop' or 'start'
TAG_FILTERS = [
{'Name': 'tag:Schedule', 'Values': ['office-hours']},
{'Name': 'tag:Environment', 'Values': ['dev', 'qa']},
]
ec2 = boto3.client('ec2', region_name=REGION)
sns = boto3.client('sns', region_name=REGION)
def find(states):
filters = TAG_FILTERS + [{'Name': 'instance-state-name', 'Values': states}]
ids = []
for page in ec2.get_paginator('describe_instances').paginate(Filters=filters):
for reservation in page['Reservations']:
ids += [i['InstanceId'] for i in reservation['Instances']]
return ids
def run():
wait = {'Delay': 15, 'MaxAttempts': 40}
if ACTION == 'stop':
ids = find(['running'])
if ids:
ec2.stop_instances(InstanceIds=ids)
ec2.get_waiter('instance_stopped').wait(InstanceIds=ids, WaiterConfig=wait)
print(f'stop requested for {len(ids)} instances')
leftovers = find(['running', 'pending'])
if leftovers:
raise RuntimeError(f'still running after stop: {leftovers}')
print('verified: 0 tagged instances still running')
else:
ids = find(['stopped'])
if ids:
ec2.start_instances(InstanceIds=ids)
ec2.get_waiter('instance_running').wait(InstanceIds=ids, WaiterConfig=wait)
print(f'start requested for {len(ids)} instances')
if __name__ == '__main__':
try:
run()
except Exception as exc:
sns.publish(TopicArn=TOPIC_ARN, Subject=f'VM {ACTION} job FAILED', Message=str(exc))
raise SystemExit(1)
Two design choices matter. The paginator means a fleet larger than one API page is not silently truncated. And the script re-queries after stopping, so it checks the result instead of trusting that the call returned.
One caution: the start branch starts every stopped VM that carries the tags, including one someone stopped on purpose.
Schedule the trigger
The simplest trigger is cron on a small always-on host. That host must not carry the Schedule tag, or it will stop itself and the job disappears. A native cloud scheduler that can run a function with a timezone setting avoids that trap. This pairs well with s3 gateway endpoint vs interface endpoint: which to pick in depth.
## UNEXECUTED EXAMPLE crontab (times are in the host's timezone, assumed UTC here)
ALERT_TOPIC_ARN=arn:aws:sns:REGION:ACCOUNT:vm-schedule-alerts
0 20 * * 1-5 ACTION=stop /usr/bin/python3 /opt/schedule/vm_power.py
0 8 * * 1-5 ACTION=start /usr/bin/python3 /opt/schedule/vm_power.py
0 * * * 0,6 ACTION=stop /usr/bin/python3 /opt/schedule/vm_power.py
The third line is a weekend sweep. It stops anything someone started on Saturday or Sunday and forgot. Cron in UTC drifts an hour against local time when daylight saving changes, so use a scheduler with timezone support if that matters to your team.
Expected output on a healthy evening, based on the script above:
stop requested for 16 instances
verified: 0 tagged instances still running
Step 4: Verify the shutdown
The script's own check is a start. For at least the first few nights, confirm independently. This command lists tagged instances that are still running, and after the stop time it should return nothing.
## UNEXECUTED EXAMPLE verification
aws ec2 describe-instances \
--filters 'Name=tag:Schedule,Values=office-hours' 'Name=instance-state-name,Values=running' \
--query 'Reservations[].Instances[].InstanceId' --output text
An empty result means the job worked. Then check your audit trail. On AWS, CloudTrail records stop and start API calls, so you can see who or what issued them, when, and against which instance. Whatever your provider, confirm the audit log shows your job's identity and not just your own console clicks.
Step 5: Plan for the failure case
The job runs Friday at 20:00. One call errors or skips some instances, nobody notices, and four VMs run all weekend. The dev.to post describes the same scenario: a silent failure shows up on the invoice, not in the UI, unless someone tells you.
The usual causes are mundane:
- A tag typo or a missing tag, so the VM never matched the filter.
- A script without pagination, so it only saw the first page of results.
- An instance in
pendingstate at stop time, which arunning-only filter skips. - A script that covers one region while VMs live in another.
- An IAM permission that was removed or never granted.
- The cron host being down, so nothing ran at all.
The script above catches causes 2, 3 and 5. Pagination handles the first, the post-stop check catches the second, and a permission error raises an exception. Each failure publishes to an SNS topic. Subscribe the person who owns the schedule to that topic by email.
Causes 1 and 4 slip past the script, because the VM sits outside the filter or region it queries. Catch them with a periodic review of running instances that have Environment set to dev or qa but no Schedule tag, in every region you use.
Also read: related topic: how to make layered box-shadow css (plus glass cards)
Cause 6 needs a different signal, because a job that never runs cannot report its own failure. Have the job ping a heartbeat on success, and alert when the ping is missing.
Pre-flight checklist
Work through this before you enable the schedule.
- Every stoppable VM carries both the environment tag and the schedule tag, with one agreed spelling.
- Production, databases, load balancers, NAT gateways and anything stateful have no schedule tag.
- The cron host or scheduler is not itself tagged for shutdown.
- The job's identity has only the permissions it needs, and its calls appear in your audit log.
- A failure sends an alert to a named human, and a missing run also triggers one.
- Weekend coverage exists: either a Friday stop with a Monday start, or a weekend sweep.
- Attached disks of stopped VMs are on your cost review list, since they keep billing.
- You ran the verification command after the first scheduled stop and again on Monday morning.
What to expect next
After the first week, compare your running-VM count at 21:00 and on Saturday with your Step 1 estimate. Expect surprises: untagged VMs, a forgotten region, someone's long-running job. Fix those with tags, not exceptions in the script.
Once stops are reliable, look at the costs a stop schedule does not touch: disks of stopped VMs, and unused resources that still bill.
Editor's note: I corrected one claim in Step 5. The original said the script covers the first five failure causes, but a tag typo and a second region both fall outside the script's filter and region, so it cannot detect them. I also limited the case-sensitivity statement to AWS, since the original gave no basis for "most providers."



