# Tf Best Practices

> Best-practice authoring guidance AND a read-only policy gate for AWS Terraform generated by a migration skill. Load during any phase that writes a terraform/ directory — first as the "what to emit" posture rules + security-baseline spec, then after writing as the deterministic policy verdict. Read-only: it reports whether the generated Terraform passes; it never edits .tf files, never touches .phase-status.json, and never decides phase completion. Complements (does not replace) terraform fmt/init/validate.

- Skill: `awslabs/tf-best-practices` (Agent Skill, multi-file: 29 files)
- Install (CLI): `npx skillmds@latest add awslabs/tf-best-practices`
- Raw SKILL.md: https://api.skillmd.com/api/skills/awslabs/tf-best-practices/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: awslabs (https://skillmd.com/u/awslabs)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/awslabs/tf-best-practices

---


# tf-best-practices — Generated-IaC posture rules + read-only policy gate

A **shared authoring guide and verdict producer**, not a workflow. It answers two
questions for a phase that generates AWS Terraform:

1. **Before writing** — "what security posture must the generated `terraform/` follow?"
   (the posture rules + the `baseline.tf` account-hardening spec)
2. **After writing** — "does the generated `terraform/` pass policy?" (a deterministic,
   **read-only** verdict + a machine-readable report)

## Routing — load the part that matches your context

This skill is entered at two touchpoints in the caller's Generate flow, with the caller's
own terraform-authoring work in between. **The caller states which touchpoint it is at when it
loads this skill**, and reads the corresponding part:

| Caller context                                              | Load                                                                                                      | Why                                                                                               |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| **About to author `terraform/`** (before writing)           | Part 1 → [`references/security-posture-rules.md`](references/security-posture-rules.md)                   | The "what to emit" AWS authoring rules (gate-enforced + authoring-only + compliance-conditional). |
| **`terraform/` written, ready to validate** (after writing) | Part 2 → [`references/terraform-validation.md`](references/terraform-validation.md) + run the gate script | The `fmt → init → validate → policy` protocol and the read-only verdict.                          |

Everything this skill states is **source-cloud-agnostic** (pure AWS Terraform). Any GCP/Heroku
detection or artifact reading is the caller's job; where a rule needs a caller-known fact (e.g.
declared compliance frameworks), the caller passes it as a **caller-context signal** — see
`references/security-posture-rules.md` § _Caller-context signals_.

## Boundary (read this first)

This unit is a **verdict producer, never a mutator**. Its entire write surface is the
JSON verdict it is asked to emit. Specifically it **MUST NOT**:

- edit, format, or rewrite any `.tf` file (the caller owns remediation),
- read or write `.phase-status.json` or any run-state file (interpreter-owned),
- decide whether a phase may complete, or prompt the user (caller policy).

The caller (a migration skill's Generate phase) owns: the fix-and-retry loop that edits
the `.tf` it generated, `terraform fmt` auto-apply, the retry/skip/abort prompt, the
Phase Completion gate, and every `.phase-status.json` write. See the consuming skill's
generate phase for how the verdict feeds those decisions.

> **Consumers (v1):** `gcp-to-aws` only. The contract is source-agnostic and designed to
> be adopted by `heroku-to-aws` later, but that wiring is intentionally out of scope for now.

## Part 1 — Authoring posture (load before writing `terraform/`)

Emit generated Terraform that satisfies the posture in
[`references/security-posture-rules.md`](references/security-posture-rules.md).

These are the "what good AWS Terraform looks like" rules. Following them makes the Part 2 gate
pass by construction. This unit does not read the caller's artifacts — it consumes only
caller-context signals the caller passes in.

> **Scope.** `security-posture-rules.md` covers, in three tiers:
>
> - **Gate-enforced** (Part 2 verifies statically): ALB TLS, no-public-database, RDS +
>   ElastiCache encryption-at-rest, no-public-DB-port ingress, no-public admin/datastore-port
>   ingress, no-wildcard-IAM.
> - **Authoring-only** (not gate-checkable, still required): `deletion_protection`,
>   master-password-via-Secrets-Manager, S3 hardening, Fargate/EKS/ECR settings, private-subnet
>   placement, backups, baseline monitoring.
> - **Compliance-conditional** (emitted when the caller declares `soc2`/`pci`/`hipaa`/`fedramp`):
>   VPC flow logs, S3 access logging, secret rotation, customer-managed KMS.
>
> Still the **caller's** own generation concern (candidates to migrate here later): the
> account-hardening `baseline.tf` layer (CloudTrail, GuardDuty, Config, Security Hub).

## Part 2 — Policy gate (run after writing `terraform/`)

Run the read-only checker against the generated directory. Resolve the script path relative to
the plugin root (`$PLUGIN_ROOT/skills/tf-best-practices/scripts/...`), the same convention the
plugin uses for its other scripts:

```bash
python3 "$PLUGIN_ROOT/skills/tf-best-practices/scripts/validate-terraform-policy.py" "$TERRAFORM_DIR" --json "$VERDICT_PATH"
```

- `$TERRAFORM_DIR` — **required, caller-supplied**: the generated `terraform/` directory
  (e.g. `$MIGRATION_DIR/terraform`). This skill never defaults or discovers it — the caller
  always passes the path it wrote Terraform to.
- `--json $VERDICT_PATH` — optional; writes a machine-readable verdict the caller can merge
  into its own `validation-report.json`.

The policy check is one stage of a larger validation flow (`fmt → init → validate → policy`).
The full protocol — including offline-fallback behavior and how the policy verdict maps into a
`validation-report.json` — is documented in
[`references/terraform-validation.md`](references/terraform-validation.md). That protocol is
descriptive: the **caller** owns the fmt/init/validate execution, the fix-and-retry loop, and
the report write; this unit contributes only the read-only policy stage + verdict shape.

### Exit codes → caller action

| Exit | stdout        | Meaning            | Caller does                                                                     |
| ---- | ------------- | ------------------ | ------------------------------------------------------------------------------- |
| `0`  | `POLICY_OK`   | posture satisfied  | proceed                                                                         |
| `1`  | `POLICY_FAIL` | violations present | read `violations[]`, edit the named `.tf` sites, re-run (caller's retry budget) |
| `2`  | (usage error) | bad path / IO      | surface to user; do not treat as pass                                           |

### Verdict shape (`--json`)

```json
{
  "check": "policy",
  "policy_status": "POLICY_OK | POLICY_FAIL",
  "violations": [
    {
      "check": "policy",
      "rule": "alb_https_listener | alb_http_redirect | no_tf_files",
      "file": "compute.tf",
      "line": 7,
      "severity": "error",
      "summary": "human-readable violation",
      "fix_hint": "concrete remediation the caller can apply"
    }
  ]
}
```

Each `violations[]` entry is **actionable evidence** — `file` + `line` + `fix_hint` tell the
caller exactly what to edit. The caller applies the edit; this unit only reports.

## Policy rules enforced today

Every rule is **fail-open on ambiguity** — it fires only on unambiguous, in-block literal
evidence, so a valid stack is never falsely blocked (a `POLICY_FAIL` is a hard completion gate
for the caller, so a false positive would block a real migration).

**Internet-facing ALB TLS posture** (an ALB is internet-facing when `internal` is absent,
`false`, or variable-driven — fail-safe):

- **`alb_https_listener`** — must have an HTTPS listener on `443` with `certificate_arn` and a
  `forward` action.
- **`alb_http_redirect`** — an HTTP `:80` listener must `redirect` to HTTPS, never `forward`
  to targets. Internal ALBs (`internal = true`) are exempt.

**Managed database exposure & encryption** (`aws_db_instance`, `aws_rds_cluster`):

- **`rds_not_public`** — must not set `publicly_accessible = true` (absent/variable → fail-open).
- **`rds_encryption_at_rest`** — must set `storage_encrypted = true`; missing or literal `false`
  fires (RDS defaults to unencrypted), variable-driven fails open. S3 is not checked (default
  SSE-S3 since Jan 2023).

**ElastiCache encryption** (`aws_elasticache_replication_group`):

- **`elasticache_encryption_at_rest`** — must set `at_rest_encryption_enabled = true`; missing
  or literal `false` fires, variable-driven fails open. `aws_elasticache_cluster` (Memcached)
  not checked.

**Security group ingress:**

- **`db_sg_no_public_ingress`** — an inline `aws_security_group` ingress covering `5432`/`3306`
  must not allow `0.0.0.0/0` or `::/0`.
- **`sg_no_public_admin_ingress`** — an inline ingress must not open a curated never-public
  admin/datastore port (`22`, `3389`, `6379`, `11211`, `27017`, `9200`/`9300`, `5601`) to
  `0.0.0.0/0` or `::/0`. Web (`80`/`443`) and app/game ports are not flagged; DB ports are
  handled by the rule above. Both check `cidr_blocks` and `ipv6_cidr_blocks` independently, so a
  benign IPv4 list does not mask an open IPv6 one. Both: separate `aws_security_group_rule` /
  `aws_vpc_security_group_ingress_rule` resources fail open (not correlated).

**IAM least-privilege** (`aws_iam_policy`, `aws_iam_role_policy`, `aws_iam_group_policy`,
`aws_iam_user_policy`):

- **`no_wildcard_iam`** — an `Allow` statement must not use `Action`/`Resource` `"*"`.
  `aws_iam_policy_document` data sources and assume-role trust policies fail open.

> The checker is a zero-dependency static HCL reader (no `terraform init`, no provider
> download) — it runs even when the registry is unreachable. It uses brace-depth matching
> for nested blocks, so a valid HTTPS listener written with a nested `forward { ... }`
> block is **not** a false failure.

## Fixtures (also the checker's regression suite)

`fixtures/terraform-policy/` holds **intentionally-shaped** Terraform used by
`scripts/test_validate_terraform_policy.py`:

- `bad-http-forward/` — internet-facing ALB that forwards plaintext HTTP → MUST `POLICY_FAIL`.
- `internal-alb-only/` — internal ALB on HTTP → MUST `POLICY_OK` (HTTP allowed internally).
- `good-https-redirect/` — the correct pattern → `POLICY_OK`.

These are deliberately non-compliant test data (never deployed). They are excluded from the
repo-wide `checkov` scan via `.checkov.yaml` `skip-path`; do **not** "harden" them — doing so
breaks the tests that assert the failure paths.

## Verification

```bash
# from skills/tf-best-practices/
uv run --python 3.12 --with pytest python -m pytest scripts/test_validate_terraform_policy.py -q
```

