lesson ── terraform-production ── ~15 мин ── 5 шагов
On real AWS, GitHub Actions authenticates through OIDC: the workflow gets a JWT, STS exchanges it for temporary credentials, and there are no long-lived access keys. LocalStack Community does not support OIDC, so we emulate it. We create an IAM role with a trust policy for a federated identity, assume it through AssumeRole, and look at how it works. The concept is the same as on real AWS.
интерактивный sandbox
Поднимется пара контейнеров: terraform 1.9 и localstack 3.8 в одной сети. В браузере откроется терминал, можно сразу terraform init. Каждый шаг проверяется автоматически. TTL 45 минут, без регистрации.
stack ── terraform · localstack · 1 GB RAM · самоуничтожается через 45 мин простоя
On real AWS this is the command:
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
On LocalStack, the same thing:
cd /home/student/tf-oidc
aws --endpoint-url=http://localstack:4566 \
iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1 2>&1 || true
aws --endpoint-url=http://localstack:4566 \
iam list-open-id-connect-providers
LocalStack may return a "not implemented" error, that is fine, Community does not support an OIDC provider. We keep going; what matters to us is the model, not the endpoint.
Let's create a simple IAM role with a trust on AssumeRole (which LocalStack does support):
cat > trust.json <<'EOF'
{"Version": "2012-10-17",
"Statement": [{"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},"Action": "sts:AssumeRole"
}]
}
EOF
aws --endpoint-url=http://localstack:4566 \
iam create-role \
--role-name tf-runner-demo \
--assume-role-policy-document file://trust.json
aws --endpoint-url=http://localstack:4566 \
iam attach-role-policy \
--role-name tf-runner-demo \
--policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
The role is created. It trusts the ec2 service (emulation; in real OIDC the trust would be to GitHub's federated identity).
✓ The role is created. Now we assume it.
CREDS=$(aws --endpoint-url=http://localstack:4566 \
sts assume-role \
--role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
--role-session-name local-demo)
echo "$CREDS" | jq '.Credentials.AccessKeyId, .Credentials.SecretAccessKey | .[0:10]'
We got temporary creds. They are short-lived (1 hour by default) and are not rotated the way ordinary access keys are.
In real GitHub Actions this is done by aws-actions/configure-aws-credentials:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/tf-runner-demo
aws-region: us-east-1
Under the hood, the exact same command sts assume-role-with-web-identity
(with an OIDC JWT) or sts assume-role (for self-assumed).
✓ The temporary creds are issued. This is the model for CI without secrets.
eval "$(aws --endpoint-url=http://localstack:4566 \
sts assume-role \
--role-arn arn:aws:iam::000000000000:role/tf-runner-demo \
--role-session-name terraform-run | jq -r '
.Credentials |
"export AWS_ACCESS_KEY_ID=" + .AccessKeyId,
"export AWS_SECRET_ACCESS_KEY=" + .SecretAccessKey,
"export AWS_SESSION_TOKEN=" + .SessionToken
')"
cat > main.tf <<'EOF'
resource "aws_s3_bucket" "oidc_demo" {bucket = "linuxlab-oidc-demo"
}
EOF
terraform init -no-color > /dev/null
terraform apply -auto-approve -no-color
terraform state list
Terraform ran on temporary credentials, not on long-lived ones. This is what a real CI with OIDC does.
✓ Apply ran on temporary creds. The OIDC principle is in place.
On real AWS the trust looks different:
cat > trust-real-oidc.json <<'EOF'
{"Version": "2012-10-17",
"Statement": [{"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": { "StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {"token.actions.githubusercontent.com:sub": "repo:linuxlab/terraform-infra:ref:refs/heads/main"
}
}
}]
}
EOF
cat trust-real-oidc.json | jq
The key fields:
Federated, the ARN of GitHub's OIDC provider in your AWS account.Action: sts:AssumeRoleWithWebIdentity, not AssumeRole, but
federated.Condition.StringEquals.aud, GitHub issues audience = sts.amazonaws.com.Condition.StringLike.sub, the main filter; it allows assume
only from a specific repo plus a specific ref/environment.The sub claim is what limits the blast radius. repo:org/*:* =
vulnerable. repo:org/specific-repo:ref:refs/heads/main =
tight.
✓ The trust policy for real OIDC is written. On real AWS, copy-paste.
OpenTofu keeps the CLI and state compatible with Terraform for the
commands in this step: migration usually goes through mv .terraform .terraform.bak; tofu init -upgrade. On a first switch, though, back
up the state and do a run on a feature branch, the differences cluster
in the newer features (variables in backend, state encryption, OCI
registry-backed modules). See tf-opentofu-parity for the full
matrix.
The antipattern, one role gh-tf-runner for dev+stage+prod.
Compromise the dev CI and you compromise prod.
The right way, three roles with different trust:
cat > roles-design.md <<'EOF'
| Role | Trust sub | Permissions |
|---|---|---|
| gh-tf-runner-dev | repo:linuxlab/infra:ref:refs/heads/* | dev-resources |
| gh-tf-runner-stage | repo:linuxlab/infra:environment:stage | stage-resources |
| gh-tf-runner-prod | repo:linuxlab/infra:environment:prod | prod-resources |
EOF
cat roles-design.md
The workflow sets its own role-to-assume depending on the target env. Environment gating through the GitHub UI (required reviewers, branch filter) limits who can assume the prod role.
On LocalStack we create a similar model, three separate roles:
for env in dev stage prod; do
aws --endpoint-url=http://localstack:4566 \
iam create-role \
--role-name "tf-runner-${env}" \--assume-role-policy-document file://trust.json
done
aws --endpoint-url=http://localstack:4566 iam list-roles \
--query 'Roles[*].RoleName' --output text
You see three tf-runner-* roles. In a real system each one is tied to a different environment in GitHub.
✓ The multi-role pattern is in place. In prod this gives you blast-radius isolation.
A bonus pattern: the plan job does not need write permissions.
gh-tf-runner-plan , read-only (ReadOnlyAccess)
trust: pull_request
gh-tf-runner-apply , full (Custom managed policy)
trust: push on main
Plan on a PR runs under read-only, so even a compromised PR with malicious HCL cannot create anything. Apply is possible only after a merge into main (a different trust).
In the trust policy:
"Condition": { "StringLike": {"token.actions.githubusercontent.com:sub": "repo:linuxlab/infra:pull_request"
}
}
pull_request, the keyword in the sub claim for the PR context. Any
PR in this repo gets access to the read-only role. The apply role
requires :ref:refs/heads/main and is unreachable from the PR
context.
This shrinks the blast radius with a compromised contributor: they can open a PR with malicious HCL → the plan job runs it → a reviewer still has to approve the merge → the apply job is already on main → it is immediately clear what was applied. The audit trail is kept.
LocalStack Community supports AssumeRole, but not real OIDC.
What we do: an IAM role with minimal S3 permissions, assume it
through sts assume-role, get temporary credentials. This is the
model for how a real workflow gets creds through
aws-actions/configure-aws-credentials.
команды
aws iam create-role ...create a role with a trust policy.aws iam attach-role-policy ...attach permissions to the role.aws sts assume-role --role-arn ...get temporary credentials.AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... terraform applyuse the temporary creds for terraform.концепции