Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@
"infrastructure/cicd/git-signing",
"infrastructure/cicd/terraform-runs-on"
]
},
{
"group": "Terraform on AWS",
"pages": [
"infrastructure/terraform/overview",
"infrastructure/terraform/aws-bootstrap",
"infrastructure/terraform/consuming-repo"
]
}
]
},
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ Terraform builds VMs and LXCs (coral). Ansible takes the inventory and configure
## Cross-cutting topics

<CardGroup cols={2}>
<Card title="Terraform on AWS" icon="diagram-project" href="/infrastructure/terraform/overview">
Per-project IAM role, GitHub OIDC, S3 native locking, SSE-S3 — the standard for any new AWS-backed Terraform repo.
</Card>
<Card title="Kubernetes overview" icon="cube" href="/infrastructure/kubernetes-overview">
OrbStack as the local control plane; what runs on K8s vs LXC vs Docker.
</Card>
Expand Down
3 changes: 3 additions & 0 deletions infrastructure/terraform-check-placement.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ Direnv already activates the Nix dev shell on `cd` — no `nix develop` wrapper
## Where to go next

<CardGroup cols={2}>
<Card title="Terraform on AWS bootstrap" icon="hammer" href="/infrastructure/terraform/aws-bootstrap">
The admin-runnable module that creates the role this placement rule applies to.
</Card>
<Card title="CI/CD policy" icon="scale-balanced" href="/infrastructure/cicd/policy">
Marketplace actions, release-please, version pinning, runner choice.
</Card>
Expand Down
211 changes: 211 additions & 0 deletions infrastructure/terraform/aws-bootstrap.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
---
title: "AWS bootstrap"
description: "Admin-runnable Terraform that provisions the state bucket, GitHub OIDC trust, and per-project IAM role for a new Terraform repo. Idempotent; runs once per project."
tier: 2
---

{/* TIER-GUARD: reference page — prerequisites, the bootstrap module, the migrate-state flow, and verify all belong together. */}

> Run this once per new project, with admin AWS credentials. Output is everything a new repo's `backend.tf` needs.

The bootstrap is plain Terraform. The first `apply` runs with local state; once the bucket exists, you uncomment the `backend "s3"` block at the top of the file and `terraform init -migrate-state` lifts the state into the bucket the bootstrap itself just created. The bucket then hosts both its own bootstrap state (`_bootstrap/terraform.tfstate`) and the consuming repo's state (`<project>/terraform.tfstate`).

## Prerequisites

- Admin AWS credentials in the shell — `aws sts get-caller-identity` returns an admin ARN.
- Terraform ≥ 1.10 or OpenTofu ≥ 1.10 on PATH.
- The new GitHub repo (`<github-org>/<github-repo>`) already exists.
- The GitHub Actions OIDC provider exists in the AWS account. Check with:

```bash
aws iam list-open-id-connect-providers \
--query 'OpenIDConnectProviderList[?contains(Arn, `token.actions.githubusercontent.com`)]'
```

If the result is empty, create it once per account (one-time, account-wide):

```bash
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
```

AWS verifies the GitHub Actions issuer's certificate chain automatically — no manual thumbprint is needed.

- Each human operator has an IAM user with MFA enabled, and a policy granting only `sts:AssumeRole` on `arn:aws:iam::<account-id>:role/tf-*` (no direct resource permissions). Operator IAM user creation is a per-operator one-time step, separate from per-project bootstrap.

## Where this lives

The recommended layout is one directory per project inside a single admin-owned repo (suggested name: `terraform-aws-foundation`):

```text
terraform-aws-foundation/
├── bootstrap/
│ ├── proxmox/
│ │ ├── main.tf # this file
│ │ └── terraform.tfvars # per-project values
│ ├── unifi/
│ │ ├── main.tf
│ │ └── terraform.tfvars
│ └── ...
└── README.md
```

Each per-project directory is independent: its own state object lives in its own bucket, so projects cannot affect each other even by accident.

## The bootstrap module

The Terraform code lives in [`dryvist/terraform-aws-template`][repo] (Apache-2.0, public). Each per-project bootstrap directory is a small root module that wires the published module to the project's values:

```hcl
terraform {
required_version = ">= 1.10"

required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}

# First `terraform apply` runs with local state. Once the bucket exists,
# uncomment this block (substitute the bucket name the apply emits) and
# run `terraform init -migrate-state` to lift state into the bucket.
#
# backend "s3" {
# bucket = "tfstate-<project>-<account-id>"
# key = "_bootstrap/terraform.tfstate"
# region = "us-east-1"
# use_lockfile = true
# encrypt = true
# }
}

provider "aws" {
region = "us-east-1"
}

module "state_backend" {
source = "git::https://github.com/dryvist/terraform-aws-template.git?ref=v0.1.0"

project = "proxmox"
github_org = "<github-org>"
github_repo = "terraform-proxmox"
branch_pattern = "main"

operator_user_arns = [
"arn:aws:iam::<account-id>:user/<operator>",
]
}

output "backend_config" { value = module.state_backend.backend_config }
output "tf_role_arn" { value = module.state_backend.tf_role_arn }
output "state_bucket" { value = module.state_backend.state_bucket }
output "state_key_prefix" { value = module.state_backend.state_key_prefix }
```

Full input / output reference and the list of underlying AWS resources live in the module repo's [README][readme]. The module pins to a tagged release (`v0.1.0` above) — breaking changes ship as new majors so existing bootstraps stay valid until you re-pin.

The S3-native lock object (`<project>/terraform.tfstate.tflock`) is just another S3 object under the same prefix as state — no separate IAM permission required, no DynamoDB table.

[repo]: https://github.com/dryvist/terraform-aws-template
[readme]: https://github.com/dryvist/terraform-aws-template/blob/main/README.md

## Bootstrap the chicken-and-egg

<Steps>
<Step title="Apply locally">
With the module block above pointing at your project's values:

```bash
terraform init # local state — no backend block yet
terraform apply
```

Confirm the apply. `terraform output backend_config` emits the ready-to-paste `backend "s3" {}` block for the consuming repo (`terraform output -raw backend_config > /tmp/backend.tf` to ship it straight to a file).
</Step>
<Step title="Uncomment the backend block">
In `main.tf`, uncomment the `backend "s3"` block at the top of the `terraform {}` block and substitute the outputs the apply just produced:

```hcl
backend "s3" {
bucket = "tfstate-proxmox-<account-id>"
key = "_bootstrap/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
encrypt = true
}
```

`encrypt = true` instructs the client to send the SSE header on every PutObject. The bucket's default SSE-S3 encryption is already configured by the module above — no `kms_key_id` is needed because there is no KMS key.
</Step>
<Step title="Migrate state into the bucket">
```bash
terraform init -migrate-state
```

Terraform prompts to copy the local state file into the bucket. Confirm. Then delete the local artefacts:

```bash
rm terraform.tfstate terraform.tfstate.backup
```

The bootstrap is now self-hosting in the bucket it created. Subsequent `terraform plan` / `terraform apply` runs against the bootstrap (for example to widen `branch_pattern` or add another operator) work like any other Terraform module.
</Step>
</Steps>

## Verify

```bash
# State bucket exists and contains the bootstrap state.
aws s3 ls s3://tfstate-<project>-<account-id>/_bootstrap/

# Role exists with the expected name.
aws iam get-role --role-name tf-<project>

# Outputs — feed these into the consuming repo's backend.tf.
terraform output -json
```

If all three succeed, the consuming repo can immediately set up its [backend.tf](/infrastructure/terraform/consuming-repo) and run its first `terraform plan`.

{/* Shape: linear chain. 5 nodes. Boundary crossings: 0. Aspect: ~5:1 LR. Pass. */}

```mermaid
%%{init: {'theme':'base','look':'handDrawn','themeVariables':{'fontFamily':'Geist','fontSize':'14px','primaryColor':'#102937','primaryTextColor':'#F4EFE6','primaryBorderColor':'#4FB3A9','lineColor':'#4FB3A9','secondaryColor':'#0B1D2A','tertiaryColor':'#1A2A38','clusterBkg':'rgba(79,179,169,0.08)','clusterBorder':'#4FB3A9'}}}%%
flowchart LR
Admin([Admin shell])
Apply([terraform apply<br/>local state])
Bucket[(State bucket created)]
Migrate([init -migrate-state])
Done([Self-hosting])

Admin --> Apply --> Bucket --> Migrate --> Done

classDef src fill:#102937,stroke:#E06B4A,stroke-width:2px,color:#F4EFE6;
classDef hop fill:#102937,stroke:#4FB3A9,stroke-width:2px,color:#F4EFE6;
classDef sink fill:#102937,stroke:#F4EFE6,stroke-width:2px,color:#F4EFE6;

class Admin src
class Apply,Migrate hop
class Bucket,Done sink

linkStyle 0,1,2,3 stroke:#F4EFE6,stroke-width:1.5px;
```

## Where to go next

<CardGroup cols={2}>
<Card title="Terraform on AWS overview" icon="diagram-project" href="/infrastructure/terraform/overview">
The isolation model and naming conventions this bootstrap implements.
</Card>
<Card title="Set up the consuming repo" icon="folder-tree" href="/infrastructure/terraform/consuming-repo">
What the new repo drops in next to use the outputs above.
</Card>
<Card title="Terraform check placement" icon="list-check" href="/infrastructure/terraform-check-placement">
Where every Terraform / OpenTofu command runs — pre-commit vs CI.
</Card>
<Card title="aws-vault profile mechanics" icon="key" href="/security/tools/aws-vault">
Operator-side credential management for the role this bootstrap created.
</Card>
</CardGroup>
Loading
Loading