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:
| Option | The tradeoff |
|---|---|
| Render | It’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 Workers | Free, and it sits right behind Cloudflare, but it also needs a long-lived AWS access key. |
| ECS Fargate | About $9 a month or more, plus networking. Also, the name would have been way too confusing. |
| Lambda | Free 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:
- The private bucket that holds the files.
- The Lambda function, its role, and its logs.
- The API Gateway in front of the function.
- The roles that let me publish and let GitHub deploy.
- The Cloudflare side, including DNS, sign-in, and the list of friends who are allowed in.
- A budget alert that emails me if the account goes over $5 a month.
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:
- The app can list and read files. It can’t upload, delete, or change anything.
- Publishing uses a separate role that can only touch Fargate’s bucket. It can’t change the bucket’s settings or permanently delete old versions.
- Deploys can update the code of this one function and nothing else.
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.