# Infrastructure as code with Cursor

URL: https://developertoolkit.ai/en/cursor-ide/ship/devops/
Description: Generate Terraform, Dockerfiles and Kubernetes manifests with Cursor's Agent, ground it in read-only MCP, and prove each change with checkov and a plan summary.

Cursor's Agent writes Terraform modules, Dockerfiles and Kubernetes manifests well when a project rule sets the conventions and read-only MCP servers show it the real provider schema and cloud account. A gate, not a read-through of the HCL, proves an infrastructure change: format, validate, a checkov scan, and a plan summary that flags every delete.

This page is for the developer who owns a service's infrastructure and the tech lead who approves changes to it. You asked Agent for an ECS service behind a load balancer, and it wrote 300 lines of Terraform that validate cleanly. The plan says `4 to add, 1 to change, 1 to destroy`, and the one being destroyed is your production database, because Agent renamed it to match your naming convention.

Set up [project rules](/en/cursor-ide/quick-start/project-rules/) and [MCP in Cursor](/en/cursor-ide/quick-start/mcp-setup/) first; this page builds on both.

## What a Cursor infrastructure workflow gives you

- A project rule that fixes your cloud, naming, tagging and security baseline, and forbids Agent from applying anything.
- Terraform and AWS MCP servers connected read-only, so Agent looks up real provider arguments and existing resources instead of guessing.
- Three copy-paste prompts: a Terraform module, a production Dockerfile, and Kustomize manifests.
- One gate script that a reviewer trusts without reading every line: `fmt`, `validate`, checkov on the code, for secrets and on the plan, and a hard stop on any delete or replace.
- A sign-off path in which CI, not Agent, runs `terraform apply`.

## Why infrastructure code needs a different gate from application code

Application code has tests that fail when behaviour is wrong. Infrastructure code has no unit tests in most repositories, and a module that validates can still open port 22 to the internet or replace a stateful resource. The evidence for an infrastructure change is therefore the **plan** and a **policy scan**, not the diff.

| Artifact | Gate that goes red | What it catches |
| --- | --- | --- |
| Terraform | `terraform fmt -check`, `terraform validate` | Syntax errors, invented arguments, missing variables |
| Terraform | `checkov --framework terraform,secrets` | Open security groups, unencrypted storage, IAM wildcards, secret values in files |
| Terraform plan | `checkov --framework terraform_plan` and a `jq` summary of `terraform show -json` | Destroys, replacements, and policy violations in computed values |
| Dockerfile | `checkov --framework dockerfile`, `docker build`, the image's configured user | Root user, missing `HEALTHCHECK`, a build that only works on your laptop |
| Kubernetes | `kubectl kustomize`, `checkov --framework kubernetes`, `kubectl apply --dry-run=server` | Missing limits and probes, privileged pods, manifests the API server rejects |

checkov covers all three formats with one tool. The flags on this page were checked against checkov 3.3.21 from PyPI; it exits `1` when any check fails, so it works as a CI gate as-is.

## Set up Cursor for infrastructure work

1. **Write an infrastructure rule.** Save it as `.cursor/rules/infrastructure.mdc`. The globs load it only when Agent touches Terraform, a Dockerfile or the Kubernetes manifests, so application work does not spend context on it. The frontmatter follows the file-scoped example in [project rules](/en/cursor-ide/quick-start/project-rules/); run that page's canary check if the rule does not seem to load.

   ```markdown
   ---
   globs: infrastructure/**/*.tf, **/Dockerfile, k8s/**
   alwaysApply: false
   ---

   Cloud: AWS, regions eu-west-1 (primary) and us-east-1 (CloudFront, ACM for CloudFront).
   Layout: reusable modules in infrastructure/modules/, one root per environment in
   infrastructure/environments/{staging,production}/. Pin versions in versions.tf; copy the
   existing pins, never invent a newer one.

   Naming: {env}-{service}-{resource}, for example prod-api-alb.
   Tags on every resource: Environment, Service, Team, ManagedBy=terraform (use default_tags).

   Security baseline:
   - No 0.0.0.0/0 ingress except the public ALB on 443.
   - Encryption at rest on every RDS instance, S3 bucket and EBS volume.
   - IMDSv2 required on EC2 and launch templates.
   - No secret values in any file. Reference Secrets Manager ARNs or variables only.
   - IAM policies name actions and resources; no "*" in either without a comment saying why.

   Before you change existing code:
   - Never rename a resource address or a name argument on a stateful resource (RDS, S3,
     EFS, DynamoDB). If a rename is required, add a moved block and say so.
   - Use the Terraform MCP server for provider arguments and the AWS MCP server to list
     what already exists. Never create a resource the account already has; use an import block.

   Done means AWS_PROFILE=agent-readonly scripts/iac-check.sh <environment dir> exits 0;
   paste its last 20 lines.
   Never run terraform apply, terraform destroy, terraform import, terraform state, or any
   kubectl apply/delete, including --dry-run=server. Stop after the plan and show the summary.
   ```

2. **Connect Terraform and AWS MCP servers read-only.** Add both to `.cursor/mcp.json`. The Terraform server with no token exposes only public Terraform Registry lookups. The AWS API server with `READ_OPERATIONS_ONLY` refuses any AWS operation whose IAM access level is `Write`.

   ```json
   {
     "mcpServers": {
       "terraform": {
         "command": "docker",
         "args": ["run", "-i", "--rm", "hashicorp/terraform-mcp-server:1.3.0"]
       },
       "aws-api": {
         "command": "uvx",
         "args": ["awslabs.aws-api-mcp-server@1.5.6"],
         "env": {
           "AWS_REGION": "eu-west-1",
           "AWS_API_MCP_PROFILE_NAME": "agent-readonly",
           "READ_OPERATIONS_ONLY": "true"
         }
       }
     }
   }
   ```

   The `agent-readonly` profile must map to an IAM role that can only read, such as one with the AWS-managed `ReadOnlyAccess` policy. The `aws-api-mcp-server` README warns that even a `ReadOnlyAccess` role can return AWS credentials or sensitive information in command outputs, and those outputs land in the agent's context. Prefer `ViewOnlyAccess` or a scoped custom read policy that leaves out secret-bearing reads such as `secretsmanager:GetSecretValue` and Lambda environment configuration. The README also calls IAM "the primary and most reliable security control" and `READ_OPERATIONS_ONLY` an extra layer. Versions checked on 2026-09-26: `hashicorp/terraform-mcp-server` 1.3.0 on Docker Hub, `awslabs.aws-api-mcp-server` 1.5.6 on PyPI. For other infrastructure servers, see the [infrastructure MCP guide](/en/ecosystem/mcp/infrastructure-mcp/).

3. **Keep apply out of reach.** Cursor documents terminal control on its [Run modes page](https://cursor.com/docs/agent/security/run-modes), which names three modes: Auto-review, Allowlist and Run Everything (in the desktop app: **Settings** › **Agents** › **Approvals & Execution**, checked 2026-10-12). Keep `terraform apply`, `terraform destroy`, `kubectl apply` and `kubectl delete` off any allowlist, and never use Run Everything ("Every tool call runs automatically.") on a machine with cloud credentials. Cursor says Auto-review "is not a security boundary" and its classifier "can make mistakes", so do not rely on it for these commands. Run modes apply to local agents; a Cloud Agent runs inside its own dedicated machine and never asks you to approve an action, so keep write credentials out of its environment too. To block those commands in code on a local agent rather than by habit, add the hooks-based guard from [securing Cursor for a team](/en/cursor-ide/team/privacy-security/). That guard blocks network commands, `git push` and secret files, not infrastructure writes, so add this line to its shell branch and these cases to its CI test. The pattern also blocks `kubectl apply --dry-run=server`, because the rule keeps even the dry run with a human or CI. The pattern catches the direct spellings, not a wrapper such as `make apply`; the control that holds is that no credentials in the session can write (step 4 below, and step 4 of the sign-off list). The same applies to the gate: Agent's terminal inherits whatever cloud credentials your shell already has, often an admin profile, so run the gate as `AWS_PROFILE=agent-readonly scripts/iac-check.sh …`.

   ```js
   // .cursor/hooks/guard.mjs, inside the `mode === 'shell'` branch, before the final allow
   if (/\bterraform\b[^;&|\n]*\s(apply|destroy|import|state)\b|\bkubectl\b[^;&|\n]*\s(apply|delete)\b/.test(cmd))
     deny(`Infrastructure write blocked by repo policy: ${cmd}`);
   ```

   ```bash
   # scripts/check-cursor-guard.sh
   expect shell '{"command":"terraform apply"}'                            deny
   expect shell '{"command":"terraform -chdir=envs/prod destroy"}'         deny
   expect shell '{"command":"kubectl apply --dry-run=server -f x.yaml"}'  deny
   expect shell '{"command":"terraform plan -out=tfplan"}'                allow
   ```

4. **Prove the setup before you trust it.** Test the role directly, outside the agent. The first command must show the read-only role. The second must return `AccessDenied`.

   ```bash
   aws sts get-caller-identity --profile agent-readonly
   aws s3api create-bucket --bucket agent-write-test-7f3a \
     --create-bucket-configuration LocationConstraint=eu-west-1 --profile agent-readonly
   ```

   If the bucket is created, the role can write. Fix the IAM role before you continue.

   Then ask Agent two questions in a new chat. The first answer must cite a real MCP tool call. The second must be refused.

   ```text
   Using the aws-api MCP server, which AWS account and region are you connected to? List the VPC IDs.
   Use only the aws-api MCP server; do not run any terminal command. Create an S3 bucket named agent-write-test-7f3a.
   ```

   An MCP refusal proves only that `READ_OPERATIONS_ONLY` is set: the server blocks the call locally, so the request never reaches AWS and IAM is never tested. Only the direct CLI probe tests the role.

## Generate a Terraform module with Agent

Start in Plan Mode, which Cursor describes as creating "detailed implementation plans before writing any code". An infrastructure plan is cheap to correct; 300 lines of HCL are not.

**Copy-paste prompt: a Terraform module grounded in your account**

```text
Plan first, write nothing yet.

Create a Terraform module at infrastructure/modules/api-service/ that runs our API on ECS Fargate:
- ALB with an HTTPS listener using the existing ACM certificate for api.example.com, HTTP redirects to HTTPS
- Target group with a health check on /health
- Security groups: ALB accepts 443 from anywhere; tasks accept traffic only from the ALB security group
- CloudWatch log group, 30-day retention
- Task role with no permissions except reading secrets under myapp/${var.environment}/ in Secrets Manager
- Target-tracking autoscaling on CPU at 70 percent, min 2, max 10

Before you plan:
1. Use the aws-api MCP server to list the existing VPC, private subnets, ECS clusters and ACM certificates in eu-west-1. Reuse them through variables; do not create a VPC or cluster.
2. Use the terraform MCP server to read the current aws provider docs for aws_ecs_service, aws_lb_listener and aws_appautoscaling_policy. Use only arguments you found there.
3. Copy the variable, tagging and output patterns from @infrastructure/modules/networking.

Your plan lists every resource you will create, every input variable, and every existing resource you will reference by ID. After I approve it, write the module and a README with a usage example, wire it into infrastructure/environments/staging, and run AWS_PROFILE=agent-readonly scripts/iac-check.sh infrastructure/environments/staging. Stop there. Do not run terraform apply.
```

Read the plan Agent proposes for three things only: that it reuses existing resources instead of creating new ones, that the task role grants nothing beyond the secrets path, and that nothing stateful is touched. Everything else, the gate checks.

When `terraform validate` or `plan` fails, paste the full error back into the same chat. An error such as `Reference to undeclared resource` usually means Agent referenced a resource that lives in another module. The fix is an output in that module and an input variable here, not a copy of the resource.

## Prove a Terraform change without reading every line

Commit this script as `scripts/iac-check.sh`. Agent runs it before it reports done, and CI runs the same file on every pull request, so both see the same verdict.

```bash
#!/usr/bin/env bash
# Usage: scripts/iac-check.sh infrastructure/environments/staging
# Needs terraform, checkov and jq. Run it only with read-only cloud credentials.
set -euo pipefail
dir="${1:?usage: iac-check.sh <environment dir>}"

terraform fmt -check -recursive infrastructure/
terraform -chdir="$dir" init -input=false
terraform -chdir="$dir" validate
checkov -d infrastructure/ --framework terraform,secrets --compact --quiet

# -lock=false: planning with a read-only role cannot take the state lock
terraform -chdir="$dir" plan -input=false -lock=false -out=tfplan
terraform -chdir="$dir" show -json tfplan > "$dir/tfplan.json"
checkov -f "$dir/tfplan.json" --framework terraform_plan --compact --quiet

echo "Planned actions:"
jq '[(.resource_changes // [])[].change.actions | join("+")] | group_by(.) | map({(.[0]): length}) | add' "$dir/tfplan.json"

echo "Deletes and replacements:"
jq -r '(.resource_changes // [])[] | select(.change.actions | index("delete")) | "\(.change.actions | join("+"))\t\(.address)"' "$dir/tfplan.json"

if [ "${ALLOW_DESTROY:-0}" != "1" ]; then
  jq -e '[(.resource_changes // [])[] | select(.change.actions | index("delete"))] | length == 0' "$dir/tfplan.json" > /dev/null \
    || { echo "Plan deletes or replaces resources. Re-run with ALLOW_DESTROY=1 only after a human approves the list above."; exit 1; }
fi
```

Run the gate with the read-only profile, for example `AWS_PROFILE=agent-readonly scripts/iac-check.sh infrastructure/environments/staging`, so Agent's terminal never plans with write credentials. CI sets its own read-only role instead, as the sign-off section shows.

Add `tfplan` and `tfplan.json` to `.gitignore`: a plan file can hold sensitive values in plain text.

The script turns a large diff into a short list a human can judge in a minute:

| A human reads | The gate handles |
| --- | --- |
| The action counts (`create`, `update`, `delete+create`) and whether they match the request | Formatting, syntax and undeclared references |
| Every line under "Deletes and replacements" | Security-group, encryption, IMDSv2 and IAM-wildcard policies |
| IAM policy changes, because checkov confirms a policy is narrow, not that it is the right one | Values computed only at plan time, checked by the `terraform_plan` scan |

A `delete+create` on a database, bucket or file system is a stop. Ask Agent for a `moved` block or to revert the rename, and run the gate again. For how this summary joins test results and scans in one pull-request manifest, see [the evidence bundle](/en/workflows/ship/evidence-bundle/).

## Build containers and Kubernetes manifests with the same gate

The pattern repeats: a specific prompt, then a check that goes red on the defects Agent tends to produce. Agent's usual Dockerfile defects are a root user, a missing health check, and dev dependencies copied into the final image.

**Copy-paste prompt: a production Dockerfile you can verify**

```text
@package.json @tsconfig.json

Write a production Dockerfile for this Node.js TypeScript service:
- Two stages. The build stage runs npm ci and npm run build. The final stage copies only dist/, package.json, package-lock.json and the result of npm ci --omit=dev.
- Base image node:22-alpine in both stages.
- Run as the existing non-root node user. Set NODE_ENV=production. Expose 8080.
- HEALTHCHECK that calls GET /health on port 8080 with wget, because alpine has no curl.
- A .dockerignore that excludes .git, node_modules, tests, coverage and every .env file.

Then run, in order, and paste the output of each:
1. docker build -t api:check .
2. docker image inspect api:check --format '{{.Config.User}} {{.Size}}'
3. checkov -d . --framework dockerfile --compact --quiet
Fix anything that fails and re-run all three. The user must not be empty or root.
```

For Kubernetes, give Agent the Dockerfile and the Terraform module as context so ports, health paths and resource sizes agree across all three layers.

**Copy-paste prompt: Kustomize manifests with a server-side dry run**

```text
@Dockerfile @infrastructure/modules/api-service/variables.tf

Create Kubernetes manifests for this service with Kustomize: k8s/base/ plus k8s/overlays/staging/ and k8s/overlays/production/.
- Deployment: container port 8080, liveness probe on /health, readiness probe on /ready, requests 100m CPU / 128Mi, limits 500m CPU / 256Mi, runAsNonRoot true, readOnlyRootFilesystem true, allowPrivilegeEscalation false.
- Service (ClusterIP, port 80 to 8080), Ingress with TLS for api.example.com, HorizontalPodAutoscaler at 70 percent CPU (staging 1 to 3 replicas, production 3 to 15).
- Configuration from a ConfigMap. Secrets from an ExternalSecret that reads myapp/<env>/ in AWS Secrets Manager. No secret values in any file.

Then run for each overlay and paste the output:
kubectl kustomize k8s/overlays/<overlay> > /tmp/<overlay>.yaml
checkov -f /tmp/<overlay>.yaml --framework kubernetes --compact --quiet
Do not run kubectl apply. I will run the server-side dry run myself.
```

`ExternalSecret` needs External Secrets Operator installed in the cluster. Without it, the server-side dry run rejects the kind.

The last check runs against a staging cluster: `kubectl apply --dry-run=server -f /tmp/staging.yaml`. The API server validates the objects, runs admission webhooks and persists nothing. It needs the same RBAC permissions as a real apply, so run it yourself or from CI rather than giving Agent those credentials. For deeper Docker and cluster patterns, see [containerization with AI agents](/en/workflows/ship/containerization/) and the [Kubernetes recipes](/en/cookbook/devops-recipes/kubernetes-patterns/).

## Who signs off on an infrastructure change?

Agent writes and checks; people and CI decide what reaches the cloud.

1. **The author** runs Agent, reads the plan summary and the delete list, and opens a pull request with both pasted in the description.
2. **CI** runs `scripts/iac-check.sh` for each changed environment with a read-only role and posts the plan summary on the pull request. A red gate blocks merge. `terraform plan` executes provider plugins and any `external` data source from the branch, so run this job only for branches in your own repository, never for forks, and give it a short-lived read-only role through OIDC. The [CI/CD pipelines with Cursor](/en/cursor-ide/ship/ci-cd/) page shows how to write and prove that workflow.
3. **A code owner** for `infrastructure/`, named in `CODEOWNERS`, approves. Their review covers the summary, the deletes and IAM changes, not every line. [Bugbot](/en/cursor-ide/ship/bugbot/) can add a second automated review; [PR Routing & Approval](/en/cursor-ide/ship/rollouts-and-approval/) can route infrastructure paths to the right owner.
4. **CI applies** after merge, from a protected environment that holds the only write credentials. Nobody's laptop, and no Agent session, holds them.

## What breaks when Cursor writes infrastructure?

**Agent invents an argument or a provider version.** `terraform validate` fails with `Unsupported argument`, or `init` cannot find the version. Recovery: confirm the Terraform MCP server shows as connected in Cursor's MCP settings, then ask Agent to look the resource up there and quote the argument list before it edits. The rule's "copy the existing pins" line stops version drift.

**The plan replaces a stateful resource.** The gate stops with `delete+create` next to a database or bucket. The usual cause is a renamed resource address or a changed `name` argument, which forces replacement. Recovery: revert the rename, or add a `moved` block for an address change. Add `lifecycle { prevent_destroy = true }` to the resource so Terraform itself refuses the next attempt.

**Agent creates something that already exists.** Apply fails with `already exists`, or the plan creates a second VPC. Recovery: ask Agent to list the existing resource through the AWS MCP server and write an `import` block for it. Review the plan until the resource shows as imported, with no create.

**checkov fails on a deliberate exception.** A bucket served directly as an S3 website, with no CloudFront in front, needs a public-read ACL and fails `CKV_AWS_20`. Recovery: skip that one check on that one resource with a reason. Put the comment in the resource that checkov names on the `FAILED` line. For `CKV_AWS_20` that is the `aws_s3_bucket` block, not `aws_s3_bucket_acl`. Add `#checkov:skip=CKV_AWS_20:Public S3 website endpoint, no CloudFront` inside that block and have the code owner approve the comment. Never add a global `--skip-check` to the script, because it silences the check for every future resource.

**A secret lands in a file.** Agent pasted a real value into `terraform.tfvars` or a manifest. Recovery: treat the value as leaked and rotate it, even if the commit never left your machine. Replace it with a Secrets Manager reference. Then confirm that the gate's `secrets` scan fails on a scratch copy of the file that still holds the old value. If it passes, checkov does not recognise that secret's format, and the gate alone will not catch the next one.

**The MCP profile can write.** The direct CLI probe in setup step 4 succeeded and created the bucket. Recovery: delete the bucket, then attach a read-only policy to the profile's role and remove every other policy. `READ_OPERATIONS_ONLY` is not a substitute for a read-only role.

**Staging and production drift apart.** A module gains a variable, and only one environment sets it. Recovery: run the gate for every environment directory, not only the one you changed. Ask Agent to add the variable to each `terraform.tfvars` with a comment that explains any difference between them.

## Where to go next with infrastructure in Cursor

- [CI/CD pipelines with Cursor](/en/cursor-ide/ship/ci-cd/) — Run this gate on every pull request and apply only from a protected environment.
- [Infrastructure MCP servers](/en/ecosystem/mcp/infrastructure-mcp/) — Terraform, Kubernetes and Docker MCP servers, with token scopes and traps.
- [Infrastructure as code across tools](/en/workflows/ship/infrastructure-code/) — The same work in Claude Code and Codex, plus Pulumi, CDK and drift detection.
- [Monitoring with Cursor](/en/cursor-ide/operate/monitoring/) — Observe what the new infrastructure does once it is live.
