Making my Terraform AI friendly
Which mostly meant having less of it.
I love to build things that solve my problems and other people's problems. If you've been a long-time reader of this blog, you probably know this already. Some of those things turn into products, like Teenage Manual and Sher, while others are just experiments I wanted to see exist, like porting Omarchy to a phone.
I've been a serial builder since long before the LLM days, but once they started getting good I could suddenly build even more, which eventually left me needing somewhere to keep track of everything, so one of the first things I built with AI back in the day was a control plane for all of it. That control plane initially looked something like this:
At this point it was basically just a dashboard showing me what was running, where it was running and whether it was still healthy.
Now, the projects themselves had been built over many years and were all over the place - some were on AWS, some on Hetzner, their domains were in Cloudflare and a few were barely more than a process running on a VM, so the dashboard had to make sense of all of them.
And sure, Terraform was probably overkill for a bunch of personal projects, but since I was building this for myself it also felt like a good excuse to get closer to it and see how far I could take the infrastructure side of things without having to justify the complexity to anyone else. I ended up with a few shared modules for running a Docker container on AWS, adding things like a Postgres database, S3 storage, networking and secrets, then a bit of Terraform for each project pulling in whichever of those it needed.
Once I'd wired that in, it gave me the ability to:
- See every project I had running, where it was deployed and which infrastructure it depended on
- Provision new projects in roughly the same way using a set of shared Terraform modules
- Check their health, last deployment and rough monthly cost without opening five different dashboards
Then I added deploys, logs, preview environments and eventually a way to archive projects from the same dashboard, which is how I ended up following the home-grown platform approach to its logical conclusion and building an entire internal development platform (whoops):
At this point, adding another project meant touching most of this.
The control plane was still full of the projects I'd added back in 2023. I'd shipped a bunch more since then, but none of them were in there because every time I thought about adding one I remembered I'd have to update the Terraform, Docker, GitHub Actions and all the metadata holding the thing together, and by then building a new project was way easier than going back and teaching this thing about it, so I just kept putting that part off.
control-plane/
├── projects.yaml
├── src/
│ ├── adapters.ts
│ ├── reconciler.ts
│ ├── operator.ts
│ ├── store.ts
│ └── server.ts
├── terraform/
│ ├── modules/
│ │ ├── service/
│ │ ├── postgres/
│ │ ├── redis/
│ │ └── storage/
│ └── projects/
│ ├── project-1/
│ ├── project-2/
│ └── project-n/
├── sample-apps/
├── docker/
│ └── prometheus.yml
├── scripts/
│ ├── deploy.sh
│ ├── preview.sh
│ ├── archive.sh
│ ├── logs.sh
│ └── destroy.sh
├── .github/workflows/
│ ├── ci.yml
│ ├── deploy.yml
│ └── preview.yml
├── public/
│ └── dashboard
├── compose.yaml
└── DockerfileI'd tried pointing an LLM at the missing projects a few times too, which usually ended with me going through its Terraform, Docker, workflow and application changes one by one to check it hadn't guessed one of my old module inputs, missed an output that needed to become an environment variable or added a resource without wiring up the permissions around it, since most of that looked completely reasonable until I tried to plan or deploy it, and around the same time I'd read some research suggesting that infrastructure is simply a different problem for agents than application code (if you're curious, you can check it out here).
In the meantime I'd also joined Encore, where I now spend a lot of my time thinking about exactly this problem, so the behemoth felt like a pretty good dogfooding test.
Now, at this point, I'm not going to walk through the rewrite file by file because that isn't really how I did it. A few years ago I would have first mapped out how every Terraform module, Docker service and deployment script should move over, worked through the migration one piece at a time and then written a technical deep dive so someone in a similar situation could understand every step and do it themselves. But this time I opened the old control-plane repo alongside the repos for the projects it was supposed to manage, pointed an agent at all of them and let it figure out how everything fit together before giving it a prompt:
From there, I mostly let Claude work through everything and only stepped in a couple of times when it made a wrong assumption:
On the first pass it kept the old Prometheus setup, Docker files and scripts around, so I had to tell it to be a bit more aggressive with anything Encore had taken over while leaving the external stuff alone. The /metrics endpoint had grown into Prometheus and Grafana with a few scripts for checking logs and status by then, but I already had the traces and logs I needed without it. The two metrics that were specific to the control plane were the other pause, until it checked how gauges worked and moved those into the application instead.
Claude working through the migration.
A few minutes later, the control plane was running again and its repo was quite a bit smaller:
›.github/−~200 LOC
›workflows/
›docker/−~45 LOC
›scripts/−~250 LOC
›src/+~100 LOC
›legacy/
›catalog/+~145 LOC
›frontend/+~30 LOC
›operations/+~235 LOC
›migrations/+~25 LOC
›reconcile/+~190 LOC
›migrations/+~25 LOC
›terraform/−~1.45k LOC
›modules/−~1.3k LOC
›postgres/−~350 LOC
›redis/−~300 LOC
›service/−~500 LOC
›storage/−~150 LOC
›projects/−~300 LOC
›project-1/−~100 LOC
›project-2/−~100 LOC
›project-n/−~100 LOC
›encore/+~150 LOC
›external/
You can click through the folders for a rough version of the diff. The green application files here belong to the control plane; the application changes for each migrated project lived in that project's own repo. The LOC is deliberately rounded, and most of what disappeared was shared provisioning and deployment config rather than functionality.
For one of the AWS projects, that part of the migration looked roughly like this:
control-plane/
-└── terraform/projects/project-1/main.tf
project-1/
-├── .github/workflows/deploy.yml
-├── Dockerfile
-├── compose.yaml
+├── encore.app
└── src/
+ ├── encore.service.ts
+ ├── db.ts
+ ├── cron.ts
└── api.tsThe API was still the same application code; the new files declared the service, database and cron job that its central Terraform config used to create for it.
Now, I'm obviously used to Encore by this point, so I knew exactly what was happening here, but I can still see how a diff like this looks a little daunting when Terraform used to be the place where all of your infrastructure decisions lived and suddenly all that configuration seems to have been swept out from under your feet, or turned into magic.
Databases and cron jobs were now declared in the application code, while I could still configure things like the size and storage of the production database for that environment, and the migrated projects worked the same way for their own databases, buckets and queues instead of describing them in projects.yaml and the shared Terraform modules. The Terraform for things Encore couldn't handle stayed around, which I'll get to in a second, and if you're curious about how this part works you can read more here.

The code says there is a database, and this is where I can decide what the production one should look like.
Two of the projects still had parts running on Hetzner, and I was using Cloudflare for the domains, so I left that Terraform alone. One still had a worker on Hetzner, which stayed where it was and talked to the rest of the application through an API URL. There was also one place where the remaining Terraform needed something from Encore: an existing SQS audit queue subscribed to one of its Pub/Sub topics, so I used Encore's Terraform provider to read the underlying SNS ARN instead of copying it over manually. (I'll probably move the remaining Hetzner parts over properly next.)
So I didn't get rid of Terraform completely, but it was mostly limited to the stuff on the edges now.
For most of the projects, the control plane was back to being mostly a dashboard. Each app now had a small status endpoint using Encore's application metadata, so the control plane could pull its current environment, deployment and API URL from there, then combine that with GitHub, Cloudflare and billing data in one place. For deploys, logs and the rest of it, I mostly just linked out to Encore from there.
For most projects, the dashboard now gets status from the app itself, while the Hetzner and Cloudflare pieces stay in Terraform.
This also meant that the playing field for an agent was no longer spread across hundreds of lines of HCL, Docker config, shell scripts and workflow YAML, and for most projects all it had to touch was the application code. The new workflow was basically to ask for an infrastructure change the same way I'd ask for any other feature, let the agent run the whole thing locally and then deploy it without also having it guess its way through Terraform modules and deployment config. This is roughly what that looks like:
Claude only had to add the feature to the application, with the infrastructure part coming down to something like this:
const healthChecks = new SQLDatabase("health-checks", {
migrations: "./migrations",
});
const checkProjects = api({}, async () => {
// Check every active project and store the result
});
new CronJob("check-project-health", {
every: "15m",
endpoint: checkProjects,
});Previously, that would have started somewhere around here:
# terraform/health-checks.tf
resource "aws_cloudwatch_event_rule" "health_checks" {
schedule_expression = "rate(15 minutes)"
}
resource "aws_ecs_task_definition" "health_checks" {
family = "${var.project_name}-health-checks"
network_mode = "awsvpc"
requires_compatibilities = ["FARGATE"]
cpu = 256
memory = 512
execution_role_arn = aws_iam_role.ecs_execution.arn
task_role_arn = aws_iam_role.control_plane.arn
container_definitions = jsonencode([{
name = "health-checks"
image = var.health_check_image
command = ["npm", "run", "health-checks"]
secrets = [{
name = "DATABASE_URL"
valueFrom = aws_secretsmanager_secret.database_url.arn
}]
}])
}
resource "aws_cloudwatch_event_target" "health_checks" {
rule = aws_cloudwatch_event_rule.health_checks.name
arn = aws_ecs_cluster.control_plane.arn
role_arn = aws_iam_role.health_check_scheduler.arn
ecs_target {
task_count = 1
task_definition_arn = aws_ecs_task_definition.health_checks.arn
network_configuration {
subnets = var.private_subnet_ids
security_groups = [aws_security_group.control_plane.id]
}
}
}# terraform/iam.tf
resource "aws_iam_role" "health_check_scheduler" {
name = "${var.project_name}-health-check-scheduler"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "events.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_role_policy" "run_health_checks" {
role = aws_iam_role.health_check_scheduler.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["ecs:RunTask", "iam:PassRole"]
Resource = "*"
}]
})
}# compose.yaml
health-checker:
build: .
command: npm run health-checks
environment:
DATABASE_URL: postgres://postgres:postgres@postgres/control_plane
depends_on:
postgres:
condition: service_healthy# .github/workflows/deploy.yml
- name: Build health-check image
run: docker build -t "$REGISTRY/control-plane:$GITHUB_SHA" .
- name: Apply scheduler changes
run: |
terraform -chdir=terraform init
terraform -chdir=terraform apply -auto-approve \
-var="health_check_image=$REGISTRY/control-plane:$GITHUB_SHA"The feature still involved a few application files, obviously, but the cron job, for example, now only existed in one place and Claude could run it together with the rest of the app while it worked, so it could catch a broken declaration before handing the change back to me.
If I were sticking with the old Terraform setup, I'd probably try to make that world smaller in the same way: fewer custom module inputs, one place generating the repeated project config and fmt, validate and plan running as part of every agent change. Pulumi is taking another route with normal programming languages, state and previews, and I think more infrastructure tools will end up giving agents a smaller set of things they can change and check for themselves. But unless I had an existing Terraform estate to work around, needed some weird provider or wanted infrastructure changes reviewed separately, I'd probably still choose to have less of it.
The control plane is still a side project whose job is to manage my other side projects, which is inherently a little ridiculous, but now I can keep adding to it without its maintenance becoming the biggest side project of the bunch.