Skip to content

Building Fargate - Part Two - Infrastructure with Terraform

Published: at 03:05 PM

tldr;

Everything Fargate runs on, on both AWS and Cloudflare, is defined in Terraform in a separate infrastructure repo.

If you missed them, the prelude covers why I built Fargate and how the pieces fit together, and part one covers the app itself.

Why Terraform?

I already had an infrastructure repo from an earlier project, so Fargate moved into it instead of getting its own setup. I’m also lazy and I don’t wanna have to be fiddling with the AWS web console to set something up or change something. DRY (Don’t Repeat Yourself) and all that. I also speak DevOps and infrastructure as code just makes sense to me. The code is the record, so if I want to know why a bucket is set up the way it is, I read the code and the commit that changed it.

It also fits how I work with agents. The agent can read the Terraform, explain what a change will do, and open a pull request, the same as with app code. More on where I draw the line on that further down.

What the repo looks like

The repo is split into two kinds of folders. Modules are reusable building blocks. Environment roots are real deployments, one folder per project and environment.

modules/      reusable building blocks
envs/         real deployments, one root per project and environment
bootstrap/    the bucket that holds everyone else's state

Each root has its own state file. State is Terraform’s record of which real resources belong to which code. Keeping them separate means a mistake while editing one project’s dev environment can’t drag prod into the same plan.

The bootstrap folder is a fun little chicken and egg problem. Every root keeps its state in an S3 bucket, but something has to create that bucket, and the bucket can’t hold its own state. So that one root keeps its state locally. If I ever lose that file the bucket keeps working, and the state can be rebuilt with terraform import.

Fargate’s bucket comes from a reusable private bucket module. Public access is blocked, it’s encrypted, it refuses plain HTTP, and it keeps old versions of files around for a while in case I overwrite something by mistake. The next project that needs a private bucket gets all of that for free.

Why Lambda?

Where to host the app got its own decision record. These are the options I looked at:

OptionThe tradeoff
RenderIt’s familiar and deploys on push, but it’s about $7 a month for an always-on instance and needs a long-lived AWS access key.
Cloudflare WorkersFree, and it sits right behind Cloudflare, but it also needs a long-lived AWS access key.
ECS FargateAbout $9 a month or more, plus networking. Also, the name would have been way too confusing.
LambdaFree at this scale, and the function gets an IAM role, so there’s no long-lived key anywhere.

With these options, the last one ended up being the winner. It’s cheap and that was pretty much requirement number one; be as cheap as possible. With a small group of close friends, Lambda stays inside its free tier. The runner up requirement was no long-lived AWS access keys. Render and Cloudflare Workers both need one sitting in an environment variable somewhere, waiting to leak. Lambda gives the function its own IAM role instead, so there’s no key to leak in the first place. Files also never pass through the function since friends download straight from S3, so Lambda’s response size limits don’t matter.

There’s one wrinkle. Cloudflare can’t proxy straight to a Lambda function URL without a feature that’s only on its Enterprise plan, so an API Gateway sits between Cloudflare and Lambda. It’s one more piece, but it’s a cheap one.

What Terraform owns

At a high level, the Fargate root manages:

One detail I like is how Terraform and deploys stay out of each other’s way. Terraform creates the Lambda function once with placeholder code and then ignores code changes from then on. Deploys own the code and Terraform owns everything around it. Without that, every terraform apply would try to roll the function back to the placeholder.

Least privilege, again

If you read my job search automation post, you know I’m a stickler about the Principle of Least Privilege. Every identity in this setup can do its one job and nothing more:

There are also no long-lived AWS access keys anywhere. I sign in with aws login, which hands out short-lived credentials, so there’s nothing sitting on disk.

Deploying without keys

Merging to main deploys. GitHub Actions runs the same checks as a pull request, uploads the new bundle to Lambda, and then calls the health check on the deployed function. If the function fails to start or answers with anything other than {"status":"ok"}, the deploy fails.

The interesting part is how GitHub signs in to AWS. It uses GitHub’s OIDC provider, which means AWS trusts a short-lived token that GitHub issues for a specific run. The deploy role only accepts runs from this one repo’s main branch. There are no AWS keys stored in GitHub at all. If you’ve ever had to rotate a leaked CI secret, you know why that matters.

Plan, then apply

Here’s where I draw the line with the agent. Claude runs terraform plan and explains what it’s about to do. I run terraform apply. That’s a working agreement and not a technical restriction, because the profile that applies changes has admin rights to the account. I’d rather be the one pulling that trigger.

I save the plan to a file and apply exactly that file:

terraform plan -out=plan.tfplan
terraform apply plan.tfplan

If anything changed between the plan and the apply, Terraform refuses and I plan again. The rule I follow is that a replace (-/+) or a destroy (-) on anything that holds data, like a bucket, means I stop and read before going any further. Usually it’s something boring like a provider upgrade changing a default or a renamed resource that needs a moved block. Usually.

Pull requests to the infra repo also run terraform fmt -check and terraform validate in GitHub Actions. Neither needs any credentials, so they’re safe to run on every change.

Adding a friend

This one’s simple. The list of friends who can sign in lives in Terraform. Adding someone means adding their email, running a plan, and applying it. There’s no app deploy and no admin screen to build.

Coming up

In the final article, I’ll cover the Cloudflare side, including how friends sign in without the app having any login code of its own.


Previous Post
Building Fargate - Part Three - Sign-In with Cloudflare
Next Post
Building Fargate - Part One - A Tiny App with Bun and Hono