Terraform from my phone

7 minute read

TL;DR

  1. An agent can run real cloud infrastructure if you make the blast radius a property of the account, not of the prompt.
  2. The human lane mostly designs itself, because AWS won’t let root delegate the things that matter most.
  3. IAM permissions boundaries turn “please be careful” into “physically can’t”.
  4. Make torn-down the default state and cost anxiety disappears.
  5. If you want a status page that can’t lie, have terraform apply write it.

Over the last two days, mostly from my phone, I’ve directed Claude through standing up a new AWS organisation, a sandboxed member account, a permissions-bounded identity for itself, and then a working AI demo on top: a court-interpretation quality tool with a streaming speech pipeline, an LLM judge running in-region on Bedrock, and a public status site. My hands touched a keyboard for maybe twenty minutes of that, almost all of it pasting pre-written blocks into CloudShell as root.

This post is about why that worked, because the interesting part isn’t the model writing HCL. It’s the boundary conditions.

The last time I did this properly

My last serious infrastructure work was at FCDO, adding Apache Superset to an existing stack. I remember thinking Terraform was genuinely cool, and I also remember it costing me a good week to get my head around it. The loop was the tax: plan, apply, cryptic error, forum archaeology, try again. None of that week was spent on the questions that actually mattered (who can touch what, what happens when this fails, what does this cost when nobody’s looking). It was all spent on syntax and state.

That week is what’s been deleted. Not because Terraform got easier, but because I stopped being the one holding it. What I hold now are the constraints, and it turns out that’s the better job: I’ve learned more about IAM boundary design in two days of reviewing an agent’s guardrails than in years of adjacent proximity to cloud teams.

The division of labour

The shape that emerged wasn’t really designed, it was forced, and the forcing function is one of my favourite AWS facts now: the root user cannot assume IAM roles. At all. Ever. Which means the most privileged identity in the estate is also the least able to delegate, so anything root-only (creating the organisation, changing the agent’s own permissions, accepting Bedrock model agreements) has to be a deliberate human act. My lane wasn’t a policy choice I had to hold with discipline. AWS holds it for me.

flowchart LR
    subgraph human["My lane · a phone and one CloudShell tab"]
        R[Me, directing by chat]
        CS[CloudShell as root<br/>pasted blocks only: bootstrap ·<br/>boundary changes · model agreements]
    end
    subgraph agent["Claude's lane · everything else"]
        CC[Claude Code session<br/>orchestrator plus cheaper subagents]
    end
    subgraph mgmt["Management account"]
        ORG[Organisation · payer-side budget<br/>alerts at 50 / 90 / 100%]
    end
    subgraph member["Member account · locked to eu-west-2 by a permissions boundary"]
        IAM[claude-dev keys, whose only power<br/>is assuming claude-orchestrator:<br/>admin ∩ boundary]
        TF[Terraform: VPC · Fargate · ALB · ECR<br/>CodeBuild · Bedrock · the status site]
        CT[CloudTrail on from minute one]
    end
    R --> CC --> IAM --> TF
    R --> CS --> ORG
    CS -.->|the only path to the leash| member

    style R fill:#2d8cff,stroke:#fff,stroke-width:2px,color:#fff
    style CS fill:#f59e42,stroke:#fff,stroke-width:2px,color:#fff
    style CC fill:#10b981,stroke:#fff,stroke-width:2px,color:#fff
    style IAM fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
    style TF fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
    style CT fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
    style ORG fill:#6366f1,stroke:#fff,stroke-width:2px,color:#fff

So the workflow became: Claude writes an idempotent bootstrap script and a paste-block for anything root-only, I read it on my phone, paste it into CloudShell, and paste back the output. Everything downstream of that happens through an identity Claude effectively designed for itself, and crucially, designed to constrain itself.

Boundary conditions are the product

This is the bit I’d emphasise to anyone trying the same. The autonomy you can safely grant is exactly proportional to how real your guardrails are. Ours, all proposed by the agent and approved by me:

  • A separate member account. Blast radius as an account property. Worst case is “close the account”, and billing shows the project as its own clean line.
  • A permissions boundary as the ceiling. The agent’s role is admin intersected with a boundary policy: everything works in eu-west-2 (London) and almost nothing works anywhere else. No Marketplace subscriptions, no reserved instances, an EC2 instance-type allowlist so a mistake can’t launch a £25-an-hour GPU.
  • The leash is out of reach. The boundary denies the agent any IAM action on its own identities or on the boundary policy itself, and any role it creates must carry the same boundary or creation fails. We tested this adversarially: role creation without the boundary, region hops, boundary stripping, all denied by the account, not by good intentions.
  • Money watches itself. A payer-side budget with alerts at 50, 90 and 100 percent (fun fact learned the hard way: member accounts under consolidated billing can’t own budgets at all).
  • Torn down is the default. The expensive pieces (load balancer, compute) are off unless a variable says otherwise, so every session ends at roughly zero pounds per day. Standing the demo up for a visitor is one command; so is putting it away.

None of this is exotic. It’s all bog-standard AWS primitives, which is rather the point: the primitives were always strong enough to hold a hard boundary. What changed is that maintaining them is no longer a week of my life.

What it actually built

The demo itself deserves its own post, but briefly: it’s a glass-box workbench exploring AI quality assurance for court interpreting (a domain where roughly 1% of interpreter bookings are ever quality-checked). A synthetic ten-minute mock hearing with 18 deliberately seeded interpretation errors runs through a real pipeline: two speech-to-text engines scored against ground truth, machine translation, and an LLM judge on Bedrock that never leaves London. The judge currently catches 94% of the seeded errors at 74% precision, and the false positives are printed on the report rather than hidden, because a QA tool that cries wolf is worse than none. There’s also a live mode for interpreters with a measured 0.4 second latency against the 3 second ceiling the interpreting literature allows. A full QA run costs about 50p.

The live assist console: rolling transcript, surfaced entities, latency meter

The judge's scorecard against seeded ground truth: recall, precision and false positives published

My favourite small mechanism is the status page. The public site needs to say whether the demo is up. Rather than a health-check service, terraform apply itself writes a status.json into the site’s bucket on every run. Since applies are the only way the infrastructure changes, the site structurally cannot lie, and the visitor’s own browser then double-checks by pinging the demo’s health endpoint.

flowchart LR
    A[terraform apply<br/>the only way demo state changes] --> S[status.json in the site bucket]
    S --> W[Static site on CloudFront]
    W --> V[Visitor's browser<br/>verifies /healthz itself]

    style A fill:#2d8cff,stroke:#fff,stroke-width:2px,color:#fff
    style S fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
    style W fill:#1e293b,stroke:#fff,stroke-width:1px,color:#fff
    style V fill:#10b981,stroke:#fff,stroke-width:2px,color:#fff

The site is live at dzpq41hjedxw9.cloudfront.net if you want to see whether the demo happens to be up as you read this.

The potholes were the fun part

Every one of these cost the FCDO version of me half a day, and cost this version of me a message:

  • Root can’t assume roles, so the bootstrap needed an ephemeral bridge user that creates itself, does one job, and deletes itself.
  • Bedrock model access silently flipped to “agreement required” mid-afternoon, hours after working invocations. The fix was root-side (Marketplace actions are deliberately denied to the agent), one paste.
  • Amazon Transcribe’s automatic language identification collapsed on code-switched courtroom audio: 44.5% word error rate. The fix (one fixed-language pass per language, since the language pair is always known from the booking) took the error rate to 8.5%, and the failure itself became demo material.
  • Something in the middle of my network path strips WebSocket upgrade headers, which produced the correct paranoid response: a polling fallback, because government networks will do the same.

Each of those was caught by a guardrail or a measurement rather than by luck, which is what let me keep directing from a phone instead of hovering over a terminal.

Learning to love it

I came back to AWS expecting the old feeling: powerful, hostile, a tax on attention. What I found is that the hostility was mostly the interface, and the interface is now negotiable. The primitives underneath (accounts as blast radii, IAM conditions as physics, budgets as tripwires, everything as code) turn out to be exactly what you want when your junior engineer is a tireless language model: AWS gives you the material to build a cage worth trusting, and the agent does the caged work better than I would.

Total cloud spend for the whole build so far: about two dollars. The week of my life it didn’t cost is the real number.