Sung J. Kang
sjk@sungjkang.com:~/writing $  
2026-08-01 software-engineeringawsarchitecture 3 min read

Keeping Side Projects Costs Stable

My EC2 t2.micro runs all of my side projects. I picked that setup on purpose, since I wanted a fixed, predictable cost instead of a surprise serverless bill.

That single instance was doing double duty. It hosted my actual side project apps, and it served every static website I’d put up alongside them.

I decided to migrate the static sites off the t2.micro and onto CloudFront + S3 instead. I like that split because static hosting shouldn’t compete with app compute on the same box, and CloudFront handles the CDN layer better than nginx on a t2.micro ever will.

The first migration went smoothly. I moved a Flutter web app to its own CloudFront distribution, dumped the built assets into S3, and updated DNS to point at the new distribution. I did the same thing for my landing page right after, moving it to its own CloudFront distribution and repointing DNS again.

Then I started migrating a second Flutter web app and hit a wall. AWS’s free tier only covers 3 CloudFront distributions, and I’d already used them up on the first two migrations.

So the plan changed. Instead of stopping at the free tier, I decided to run the rest of my apps on pay-as-you-go CloudFront distributions. Pay-as-you-go still comes with its own free tier underneath it, since AWS gives every account 1TB of data transfer out and 10 million requests per month at no cost, regardless of how many distributions you’re running. Since I’m not running dozens of these, the actual bill on top of that free allowance was small, but I still wanted a hard ceiling on it rather than trusting myself to notice a bill creeping up.

I’m a strong believer in enforcing cost limits with automation instead of just checking the billing dashboard periodically. I set up an AWS Budget capped at $20 across all resources. That budget publishes to an SNS topic when I cross 90% of the cap, which is $18. A Lambda function is subscribed to that topic, and when it fires, it shuts down the pay-as-you-go CloudFront distributions before I ever see a real spike on the bill.

The Lambda itself is simple. It lists every CloudFront distribution in the account, skips anything already disabled, and for the rest pulls the current config, flips Enabled to false, and pushes the update back with the ETag it just fetched, since CloudFront requires that ETag to prevent overwriting a config that changed between the read and the write.

const { CloudFrontClient, ListDistributionsCommand, GetDistributionConfigCommand, UpdateDistributionCommand } = require("@aws-sdk/client-cloudfront");
const client = new CloudFrontClient({});

exports.handler = async (event) => {
    console.log("Budget threshold breached! Executing CloudFront Kill Switch...");
    
    // 1. Fetch all CloudFront distributions in the account
    const listRes = await client.send(new ListDistributionsCommand({}));
    const distributions = listRes.DistributionList?.Items || [];

    for (const dist of distributions) {
        if (!dist.Enabled) {
            console.log(`Distribution ${dist.Id} is already disabled.`);
            continue;
        }

        console.log(`Disabling Distribution: ${dist.Id}...`);
        
        // 2. Fetch current config + ETag
        const configRes = await client.send(new GetDistributionConfigCommand({ Id: dist.Id }));
        const currentConfig = configRes.DistributionConfig;
        const etag = configRes.ETag;

        // 3. Set Enabled to false
        currentConfig.Enabled = false;

        // 4. Update the distribution to disable it
        await client.send(new UpdateDistributionCommand({
            Id: dist.Id,
            DistributionConfig: currentConfig,
            IfMatch: etag
        }));

        console.log(`Successfully disabled Distribution ${dist.Id}`);
    }
};

It’s account-wide by design. I didn’t bother scoping it to specific distribution IDs, since the whole point is a kill switch for everything once I’ve blown past my budget.