Terraform Lesson 48 of 89

Terraform on AWS: The aws Provider, Authentication (Profiles/Assume-Role/OIDC) & Remote State in S3 + DynamoDB

Every AWS-with-Terraform lesson you will ever read assumes three things already work: Terraform knows which AWS account and region it is talking to (the aws provider), Terraform is allowed to talk to it (an authenticated identity carrying the right IAM permissions), and Terraform can safely remember what it built (remote state with locking). Get those three right once and everything else — VPCs, EC2 fleets, RDS, EKS, Lambda — is just more resource blocks. Get any of them wrong and you hit the same wall of confusing errors: NoCredentialProviders: no valid providers in chain, AccessDenied on an action you thought you had, Error acquiring the state lock, or the quietly catastrophic one where two engineers apply against local state and silently clobber each other’s infrastructure.

This lesson builds that foundation properly, and it is the on-ramp for the entire real-world AWS track. You will declare the hashicorp/aws provider with a pinned version, a region, and estate-wide default_tags; learn the AWS credential chain — the fixed order in which the provider hunts for credentials — and set up each practical link: static keys (env vars, and why long-lived ones are a footgun), a shared profile / IAM Identity Center (SSO) login, cross-account assume-role, an EC2 instance profile / ECS task role, and OIDC / GitHub Actions federation (short-lived tokens, no stored secret — the modern default). Then you will move state off your laptop into an encrypted S3 bucket, lock it with a DynamoDB table and the newer S3-native lockfile, solve the chicken-and-egg problem of creating the very bucket that holds your state, and wire cross-stack reads with terraform_remote_state.

Because this is the foundation the rest of the course stands on, it is relentlessly hands-on: complete .tf files you can copy verbatim, a real terraform init → plan → apply walkthrough, aws CLI verification of what Terraform created, and a terraform destroy to clean up. It assumes core Terraform — HCL, resources, variables, state, the plan/apply workflow — from the course’s foundation tier; if any of that is fuzzy, the Terraform fundamentals: HCL, providers, state & the workflow lesson is the prerequisite. Here we make that generic knowledge AWS.

What you’ll build

The scenario is the one every team faces on day one: you have an AWS account and you want to manage its resources with Terraform, from your laptop today and from a CI/CD pipeline tomorrow, without ever pasting a long-lived access key into a file or racing a colleague on shared state. By the end you will have a working Terraform root module that authenticates to AWS, creates a real VPC, and stores its state in a locked, encrypted S3 object — the exact skeleton you will copy into every future AWS project.

Concretely, you will produce a small set of files — versions.tf (the provider and backend), providers.tf (the aws provider block with a region and default_tags), variables.tf, main.tf (a VPC, your first managed resource), and outputs.tf — and run them end to end. Along the way you will stand up a dedicated state S3 bucket (versioned, encrypted, public access blocked) and a DynamoDB lock table, point Terraform’s backend at them, and watch terraform init migrate your state from the laptop into S3. You will authenticate as yourself via a profile for the interactive build, then wire the same configuration to run non-interactively under an assumed role and under OIDC — the two ways it will run in automation.

Why Terraform for this at all, rather than the console, a pile of aws CLI commands, or CloudFormation/CDK? Because Terraform gives you a declarative, version-controlled, plan-before-apply description of your AWS estate that is identical whether a human or a pipeline runs it, with a state file for precise diffs and drift detection — where the console is unauditable click-ops, raw aws scripts are imperative and non-idempotent, and CloudFormation/CDK are AWS-only with coarser previews. The honest comparison for this task — provisioning and continuously managing AWS infrastructure:

Approach Declarative? Idempotent Plan preview State / drift Multi-cloud Best for
AWS Console No (click-ops) No No None No Learning, one-off inspection
aws CLI scripts No (imperative) Rarely No None No Glue, quick fixes, bootstrap
CloudFormation Yes (YAML/JSON) Yes Change sets (coarse) AWS-managed No AWS-only shops avoiding a tool
AWS CDK Imperative → CFN Yes Change sets via cdk diff AWS-managed No Teams wanting a real language
Terraform (aws) Yes (HCL) Yes terraform plan Explicit state + drift Yes Repeatable, reviewable, portable IaC

The architecture you are wiring together has three moving parts on the request path plus a state plane, and the diagram below is the mental model to keep open for the rest of the lesson.

Terraform on AWS architecture: an identity — a shared profile or IAM Identity Center SSO, a cross-account assume-role, or OIDC federation from GitHub Actions — authenticates the aws provider through the AWS credential chain; the provider is pinned with a version, a region and default_tags; an IAM role with a least-privilege policy authorises its calls; Terraform then creates and manages AWS resources starting with a VPC; and it records everything as a Terraform state object in an encrypted, versioned S3 bucket that a DynamoDB lock table — or the newer S3-native lockfile — locks on every write, bootstrapped by creating the bucket and table first

Reading it left to right: an identity (a profile/SSO login, an assumed role, or an OIDC token) authenticates the aws provider through the credential chain; an IAM role authorises each API call; Terraform then creates and manages AWS resources; and it persists state into an encrypted S3 bucket that a DynamoDB table (or S3-native lockfile) locks on every write. The six badges mark the decisions that trip people up — credential-chain order, OIDC keyless CI, the least-privilege role, remote state in S3, the lock choice, and bootstrap ordering — each a section below.

The aws provider: required_providers, version pinning, region & default_tags

A Terraform provider is the plugin that translates your HCL resource blocks into AWS API calls. For AWS the provider is hashicorp/aws, which manages virtually the entire AWS surface — VPCs, EC2, S3, IAM, RDS, EKS, Lambda, and thousands of resources more. (A sibling, awscc, uses the Cloud Control API to expose newer resources sooner; aws remains the mature default and is what this lesson uses.)

You declare it in a terraform {} block with required_providers, and you pin the version — never let a fresh init silently pull a new major that renames arguments under you. The deep mechanics of version constraints, the dependency lock file, and provider aliases are covered in Terraform providers deep dive: versions, aliases & the lock file; here is the AWS-specific shape:

# versions.tf
terraform {
  required_version = ">= 1.6"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"   # allow 5.x, refuse 6.0 — pin tighter (e.g. ~> 5.60) in prod
    }
  }
}

The required_providers entry has two parts, and the source address matters more than beginners expect:

Argument Example What it does Gotcha
source "hashicorp/aws" Registry address namespace/type Omitting it makes Terraform guess hashicorp/<name>; always write it explicitly
version "~> 5.0" Version constraint for init to resolve Unpinned = a future init can jump majors and break your config

The constraint operators you will actually use, and what each admits:

Constraint Meaning Admits Refuses When to use
~> 5.0 Pessimistic, minor-level 5.1, 5.60, 5.99 6.0 Roots/modules that want 5.x fixes, not a major
~> 5.60.0 Pessimistic, patch-level 5.60.1, 5.60.9 5.61.0 Tightest sane pin for production stability
>= 5.0, < 6.0 Explicit range any 5.x 6.0+ Same effect, spelled out
= 5.60.0 Exact only 5.60.0 everything else Reproducing a specific bug/version

⚠️ AWS provider v6 exists. Provider 6.0 (2025) added enhanced multi-region support (a per-resource region argument, so one provider can target several regions) among other changes. Most production code and community modules still target ~> 5.0, which is the widely compatible pin used throughout this lesson; adopt v6 deliberately, read its upgrade guide, and pin tighter (~> 6.0) once you do. The pattern — pin a major with ~>, upgrade on purpose — is the point.

The provider block: region and default_tags

Unlike some providers, the aws provider has no mandatory block — but it must resolve a region (from the block, AWS_REGION, or a profile) or every call fails. The two arguments you set on essentially every project are region and default_tags:

# providers.tf
provider "aws" {
  region = var.aws_region   # e.g. "ap-south-1" (Mumbai)

  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Environment = var.environment
      Project     = "kloudvin"
    }
  }
}

default_tags is one of the highest-leverage features in the whole provider: every taggable resource this provider creates inherits these tags automatically, so you write your governance tags once instead of on every resource. A resource’s own tags merge on top and win on a key conflict. The behaviour to internalise:

Aspect default_tags Per-resource tags
Scope Every taggable resource under the provider Only that resource
Precedence Base layer Overrides default on key conflict
Typical use ManagedBy, Environment, Project, cost centre Name, resource-specific labels
Seen in state/plan Merged into each resource’s effective tags As written
Gotcha Setting the same key in both once caused perpetual diffs (largely resolved in v5) Use ignore_tags for tags set out-of-band

The core provider arguments you will actually touch — most can also come from the environment or a profile, which is how CI passes them without editing your files:

Provider argument Env / source equivalent Purpose Notes
region AWS_REGION / AWS_DEFAULT_REGION Which region to manage Required (block, env, or profile)
profile AWS_PROFILE Named profile from ~/.aws Local dev / SSO
access_key / secret_key AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY Static credentials ⚠️ avoid literals in .tf
token AWS_SESSION_TOKEN Session token for temp creds Set with the above for STS creds
assume_role { … } AWS_ROLE_ARN (+ session file) Assume an IAM role Cross-account, CI
default_tags { … } Estate-wide tags No env equivalent
allowed_account_ids Guard against wrong-account applies Safety net; errors if account not in list
retry_mode / max_retries AWS_RETRY_MODE Throttling behaviour adaptive for busy accounts

The golden rule: secrets never go in .tf files. A region is not a secret; a role ARN is not a secret; a long-lived secret_key absolutely is, and belongs only in a credential store or — better — avoided entirely via a profile, an instance role, or OIDC.

The AWS credential chain: how Terraform finds credentials

The aws provider needs authenticated credentials before it can make a single API call, and it finds them by walking a fixed search order called the credential chain (the same one the AWS SDKs and CLI use). Understanding this order is the whole game: almost every “it works on my laptop but not in CI” bug is two links of this chain fighting. The first link that resolves wins, and the rest are never consulted:

Order Source How the provider detects it Typical use
1 Static creds in the provider block access_key/secret_key/token set literally Discouraged — never commit
2 Environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN CI injecting temp creds
3 Shared credentials / config files ~/.aws/credentials, ~/.aws/config, AWS_PROFILE (incl. SSO) Local dev
4 Assume-role / web identity assume_role {} block, or AWS_ROLE_ARN + web-identity token Cross-account, OIDC CI
5 Container credentials (ECS / EKS) AWS_CONTAINER_CREDENTIALS_*, or IRSA / Pod Identity token Terraform in a container/pod
6 EC2 instance profile (IMDS) IMDSv2 metadata endpoint Terraform on an EC2 runner

A single command tells you which link actually won and who Terraform will act as — run it before you debug anything else:

aws sts get-caller-identity
# { "UserId": "...", "Account": "111122223333", "Arn": "arn:aws:iam::111122223333:user/vinod" }

Now each practical method, in the order you will meet them.

Method 1 — Static access keys (env vars) — and why to avoid long-lived ones

The most basic method: an IAM user’s access key ID and secret access key, supplied as environment variables. Terraform (link 2 of the chain) picks them up with no provider changes:

export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
# Temporary (STS) credentials ALSO need the session token:
export AWS_SESSION_TOKEN="FQoGZXIvYXdzE...=="
export AWS_REGION="ap-south-1"

terraform plan   # the provider reads these automatically — no provider edits

This works, and for a five-minute experiment it is fine. But long-lived IAM user keys are the single worst credential in AWS: setup is trivial, but they never expire, grant standing access, are rotated only manually (and so rarely are), and a single leak is a full account breach with no natural revocation — which is exactly why every other method below issues temporary credentials instead.

⚠️ Never commit an access key, and prefer never creating a long-lived one. Not in .tf, not in .tfvars, not in a committed .env. If a key lands in git, deactivate and delete it immediately (aws iam delete-access-key) and treat the account as compromised. The safest key is the one that doesn’t exist — every method below issues temporary credentials instead.

Method 2 — Shared profiles and IAM Identity Center (SSO)

The right choice on your laptop is a named profile in ~/.aws. A profile can hold static keys (better than env vars, but still long-lived) or — far better — an IAM Identity Center (SSO) session that issues short-lived credentials on demand. The SSO setup lives in ~/.aws/config:

# ~/.aws/config
[sso-session kloudvin]
sso_start_url            = https://kloudvin.awsapps.com/start
sso_region               = ap-south-1
sso_registration_scopes  = sso:account:access

[profile kloudvin-dev]
sso_session    = kloudvin
sso_account_id = 111122223333
sso_role_name  = PowerUserAccess
region         = ap-south-1

You log in once (a browser flow); the CLI caches short-lived credentials, and Terraform uses them via the profile:

aws sso login --profile kloudvin-dev      # opens a browser, caches temp creds
export AWS_PROFILE=kloudvin-dev           # Terraform (chain link 3) uses this profile
terraform plan

You can also pin the profile in the provider block (profile = "kloudvin-dev"), but AWS_PROFILE keeps the code environment-agnostic. Profile-based auth at a glance:

Profile flavour Where credentials come from Expire? Best for
SSO / Identity Center Short-lived, minted on aws sso login Hours (re-login) The modern local-dev default
Static-key profile aws_access_key_id in ~/.aws/credentials Never Legacy; prefer SSO
source_profile + role_arn Assumes a role from a base profile Session length Cross-account from the CLI
credential_process An external helper prints JSON creds Per helper Vault / custom brokers

Method 3 — Cross-account assume-role (role_arn)

Real organisations run many AWS accounts (dev, staging, prod, security, logging). The standard pattern is to authenticate once in a base account, then assume an IAM role in the target account. Terraform models this directly with an assume_role block on the provider:

provider "aws" {
  region = "ap-south-1"

  assume_role {
    role_arn     = "arn:aws:iam::444455556666:role/terraform-exec"
    session_name = "terraform-kloudvin"
    external_id  = "kloudvin-tf"   # optional — required when a third party assumes the role
  }
}

Terraform calls STS AssumeRole, receives temporary credentials scoped to terraform-exec in account 444455556666, and acts as that role. The target role’s trust policy must allow your base identity to assume it. The assume_role arguments worth knowing:

assume_role argument Purpose Notes
role_arn The role to assume Required
session_name Names the session in CloudTrail Make it identifiable (who/what)
external_id Shared secret for third-party trust Defeats the “confused deputy” problem
duration Session length (e.g. "1h") Up to the role’s max session duration
policy / policy_arns Further narrow the session Session policies for extra least-privilege
tags / transitive_tag_keys Session tags ABAC / propagate to chained assumptions

The cross-account deep dive (external IDs, confused-deputy, session policies) is its own topic; here the takeaway is that assume-role gives you temporary, auditable, cross-account credentials with no stored secret — the building block for both multi-account Terraform and the OIDC method below.

Method 4 — EC2 instance profile / ECS task role (Terraform running in AWS)

When Terraform runs on an AWS compute resource — a self-hosted CI runner on an EC2 instance, a Terraform step in an ECS task, a job in an EKS pod — you attach an IAM role to that compute and Terraform picks up its credentials automatically. No keys anywhere; AWS issues and rotates them:

Runtime Mechanism Chain link You configure
EC2 instance Instance profile via IMDSv2 6 Attach an instance profile to the instance
ECS task Task role via container credential endpoint 5 Set taskRoleArn on the task definition
EKS pod IRSA (web identity) or Pod Identity 5 Annotate/associate the service account with a role
Lambda Execution role (env) The function’s role

On an EC2 runner you literally set nothing in Terraform — the provider reads temporary credentials from the Instance Metadata Service:

# On an EC2 instance with an attached instance profile:
aws sts get-caller-identity   # shows the assumed instance-profile role — no keys set
terraform plan                # provider uses IMDS creds automatically

The catch is obvious: this only works inside AWS. Your laptop and GitHub-hosted runners can’t reach IMDS — for those, OIDC is the no-secret answer.

Method 5 — OIDC / GitHub Actions federation (the modern CI default)

OIDC (OpenID Connect) federation is how you authenticate a pipeline to AWS with no stored secret at all. You register GitHub’s OIDC provider in IAM once, create a role whose trust policy trusts tokens from a specific repo/branch, and in the workflow the aws-actions/configure-aws-credentials action swaps a short-lived GitHub OIDC token for STS credentials via AssumeRoleWithWebIdentity. Nothing to store, nothing to rotate, nothing to leak.

Set it up once. Register the OIDC provider and create the role with a trust policy pinned to your repo:

// Trust policy on arn:aws:iam::111122223333:role/gha-terraform
{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Principal": { "Federated": "arn:aws:iam::111122223333: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:kloudvin/infra:ref:refs/heads/main" }
    }
  }]
}

The sub condition is the security boundary — it must match the workflow’s OIDC claim exactly. Common sub shapes:

Pipeline trigger sub claim value
Branch repo:ORG/REPO:ref:refs/heads/main
Tag repo:ORG/REPO:ref:refs/tags/v1.2.3
Pull request repo:ORG/REPO:pull_request
GitHub Environment repo:ORG/REPO:environment:production

Then the workflow requests the token (id-token: write) and assumes the role — no secret in sight:

# .github/workflows/terraform.yml
permissions:
  id-token: write     # REQUIRED to mint the OIDC token
  contents: read

jobs:
  apply:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111122223333:role/gha-terraform
          aws-region: ap-south-1
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform apply -auto-approve

The action exports AWS_ROLE_ARN and a web-identity token file into the environment; Terraform’s provider (chain link 4) uses them with no HCL changes. If you prefer to keep it in HCL — for non-GitHub runners — the provider has a matching block:

provider "aws" {
  region = "ap-south-1"
  assume_role_with_web_identity {
    role_arn                = "arn:aws:iam::111122223333:role/gha-terraform"
    web_identity_token_file = "/path/to/token"   # or web_identity_token
  }
}

This is the method to standardise on for CI. The full auth landscape, ranked by security posture — OIDC ≈ instance/task role > assume-role from SSO > static keys:

Method Secret stored? Where it belongs Expires? Best for
Static access keys Yes (long-lived) Nowhere, ideally Never Quick local test only
Profile / SSO No (SSO) / keys (static) Local dev Hours (SSO) Interactive work on your laptop
Assume-role No (temp STS creds) Cross-account CLI/CI Session length Multi-account Terraform
Instance / task role No (platform-issued) Terraform on AWS compute Auto-rotated Self-hosted runners in AWS
OIDC federation No (short-lived token) GitHub/GitLab pipelines Minutes (per run) The modern default for CI/CD

IAM: the least-privilege role the Terraform identity needs

Authentication proves who the identity is; IAM authorization decides what it may do. An identity Terraform authenticates with but that lacks the right permissions produces the most common runtime error on AWS:

Error: creating EC2 VPC: AccessDenied: User: arn:aws:iam::111122223333:role/terraform-exec
is not authorized to perform: ec2:CreateVpc on resource: arn:aws:ec2:ap-south-1:111122223333:vpc/*
because no identity-based policy allows the ec2:CreateVpc action

The discipline is least privilege: the Terraform role’s policy grants exactly the actions this stack uses, plus the S3/DynamoDB permissions for its own state, and nothing else. The options, weakest-to-strongest posture:

Policy approach Example Blast radius Use when
AWS managed AdministratorAccess one attach Whole account Never in prod — bootstrap/lab only
AWS managed, scoped AmazonVPCFullAccess, AmazonS3FullAccess Per service Fast start, still broad
Customer-managed policy your own JSON, specific actions Exactly what you list The production default
+ permissions boundary boundary caps the max Can’t exceed the ceiling Delegated / self-service accounts
+ session policy narrow on assume_role Per apply Extra runtime least-privilege

A minimal customer-managed policy for a Terraform role always needs its state permissions on top of the resource actions — this is the piece people forget, and it manifests as a backend error rather than a resource error:

{
  "Version": "2012-10-17",
  "Statement": [
    { "Sid": "State", "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject", "s3:ListBucket"],
      "Resource": [
        "arn:aws:s3:::kloudvin-tfstate-111122223333",
        "arn:aws:s3:::kloudvin-tfstate-111122223333/*"
      ] },
    { "Sid": "Lock", "Effect": "Allow",
      "Action": ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"],
      "Resource": "arn:aws:dynamodb:ap-south-1:111122223333:table/terraform-locks" }
  ]
}

Multiple providers: alias for multi-region & multi-account

By default a configuration has one aws provider in one region and one account. Two common needs break that: a resource that must live in a different region (the classic being an ACM certificate for CloudFront, which AWS requires in us-east-1), and managing several accounts from one root. Both are solved with provider aliases — additional named provider instances you attach to specific resources or modules:

# Default provider — ap-south-1 (Mumbai)
provider "aws" {
  region = "ap-south-1"
}

# Aliased provider — us-east-1, for CloudFront/ACM
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

# Aliased provider — a different ACCOUNT via assume-role
provider "aws" {
  alias  = "prod"
  region = "ap-south-1"
  assume_role {
    role_arn = "arn:aws:iam::444455556666:role/terraform-exec"
  }
}

You then point a resource or module at an alias with the provider (or providers) argument:

# An ACM cert that MUST be in us-east-1 for CloudFront
resource "aws_acm_certificate" "cdn" {
  provider          = aws.us_east_1
  domain_name       = "cdn.kloudvin.com"
  validation_method = "DNS"
}

# A whole module deployed into the prod account
module "network_prod" {
  source    = "./modules/network"
  providers = { aws = aws.prod }
}

When to reach for an alias:

Need Pattern Example
Region-pinned resource alias in that region + provider = ACM for CloudFront in us-east-1
Active/DR across regions Default + one alias per region Replicate an S3 bucket, RDS read replica
Cross-account management alias with assume_role per account A landing-zone root managing many accounts
Provider-level tag/config split Aliases with different default_tags Different tagging per environment provider

The full mechanics of aliases and passing providers into modules are in the providers deep dive; the rule of thumb is: one region + one account per provider instance, add an alias for each additional region or account.

Remote state in S3: the backend “s3” block

Terraform records everything it manages in a state file. By default that file (terraform.tfstate) sits on your local disk — fine for a solo experiment, disastrous for a team: it can’t be shared, isn’t locked (two applies race and corrupt it), holds secrets in plaintext on a laptop, and vanishes if the disk dies. A remote backend moves state to shared, durable, lockable storage. On AWS that backend is s3, and it stores state as an object in an S3 bucket. (The full taxonomy of backend types and migration mechanics lives in Terraform backends deep dive: local, remote, types & migration; the team-scale patterns are in Terraform remote state at scale.)

A real backend "s3" block names the bucket, the object key, the region, a lock mechanism, and encryption:

# versions.tf  (backend goes inside the SAME terraform {} block)
terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }

  backend "s3" {
    bucket         = "kloudvin-tfstate-111122223333"  # globally unique
    key            = "prod/network/terraform.tfstate" # the object path — one per stack
    region         = "ap-south-1"
    encrypt        = true                              # server-side encrypt the state object
    dynamodb_table = "terraform-locks"                 # classic lock table (see below)
    # use_lockfile = true                              # newer S3-native lock (see below)
  }
}

Every argument and why it is there:

Backend argument Required Purpose Notes
bucket Yes S3 bucket holding state Globally unique; often suffix the account id
key Yes Object path = this stack’s state Use a path per stack, e.g. prod/network/terraform.tfstate
region Yes Bucket’s region Can also come from AWS_REGION
encrypt Recommended Server-side encrypt the state object true — state holds secrets in plaintext
dynamodb_table Legacy Table for state locking Deprecated in TF 1.11; see S3-native below
use_lockfile Modern S3-native locking, no table Terraform 1.10+
kms_key_id Optional Encrypt with a customer-managed KMS key Tighter than the default SSE-S3
profile / role_arn CI Auth to the backend Backend authenticates separately (see below)
workspace_key_prefix Optional Prefix for workspace state keys When you use CLI workspaces
acl Optional Object ACL Usually leave default; bucket owner enforced

Two design rules pay off immediately. First, one key per stack — never share a single state object across unrelated stacks; give each root module its own key (prod/network/…, prod/eks/…) so their state and their locks are independent. Second, isolate environments — a dev/ vs prod/ key prefix (or separate buckets entirely for hard isolation) keeps a dev apply from ever touching prod state.

State locking: DynamoDB table vs the S3-native lockfile

The reason two people can’t corrupt shared state is locking. On S3 there are now two mechanisms, and knowing both — and that the newer one is superseding the older — is exactly the kind of currency this lesson is about:

The comparison, and the migration path:

DynamoDB table (dynamodb_table) S3-native lockfile (use_lockfile)
Extra resource A DynamoDB table None (lock object in the state bucket)
Introduced Original S3 backend Terraform 1.10
Status Deprecated in 1.11 (still works) Current recommendation
How it locks LockID item in the table .tflock object via S3 conditional write
Cost Per-request DynamoDB (tiny) None beyond S3 requests
Extra IAM dynamodb:*Item on the table s3:PutObject/DeleteObject (already have it)
Migrate Set use_lockfile = true; keep the table briefly, then drop it

For a brand-new project in 2026, use use_lockfile = true and skip DynamoDB entirely. For an existing project on a DynamoDB table, you can run both during a transition (set use_lockfile = true while keeping dynamodb_table), then remove the table argument once every collaborator is on Terraform ≥ 1.10. Either way, when a run crashes mid-apply the lock can be left held, and the next run reports:

Error: Error acquiring the state lock

Lock Info:
  ID:        3f2b1c9a-1234-5678-9abc-def012345678
  Operation: OperationTypeApply
  Who:       vinod@laptop
  Created:   2026-07-09 06:14:22 UTC

Only after confirming no apply is genuinely still running do you break it with the lock ID:

terraform force-unlock 3f2b1c9a-1234-5678-9abc-def012345678

Never wire force-unlock into automation — breaking a lock that a live apply still holds is exactly how you corrupt state.

Authenticating to the backend

The backend authenticates separately from the provider (it’s initialised earlier, at init, before the provider even loads) — but on AWS it uses the same credential chain, so a working AWS_PROFILE or role usually just works for both. When they need to differ (a state bucket in a separate “shared services” account), the backend takes its own profile, role_arn, or assume_role arguments. The identity needs S3 access on the bucket (s3:GetObject/PutObject/ListBucket) and, if you use the classic lock, DynamoDB access on the table (dynamodb:GetItem/PutItem/DeleteItem) — exactly the state policy shown earlier.

The chicken-and-egg bootstrap

Here is the puzzle: the backend needs an S3 bucket (and maybe a DynamoDB table) to hold state, but you manage those with Terraform, which needs the backend. You cannot have Terraform create the very bucket its own backend points at in one shot. You break the cycle by creating the state store first, then pointing the backend at it. Two approaches:

Bootstrap approach How Pros Cons
Local-state Terraform (recommended) A tiny root with the default local backend creates the bucket + table, then you migrate Fully in Terraform, reviewable, re-usable A little local state for the bootstrap itself
AWS CLI bootstrap aws s3api / aws dynamodb commands create them No state to babysit, one-time Imperative — document/script it

The local-state Terraform bootstrap is the clean default — a small root you apply once, that itself creates a properly hardened state bucket:

# bootstrap/main.tf  — uses the default LOCAL backend (no backend block yet)
resource "aws_s3_bucket" "tfstate" {
  bucket = "kloudvin-tfstate-111122223333"   # globally unique
}

resource "aws_s3_bucket_versioning" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  versioning_configuration { status = "Enabled" }   # recover a clobbered state
}

resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
  bucket = aws_s3_bucket.tfstate.id
  rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } }
}

resource "aws_s3_bucket_public_access_block" "tfstate" {
  bucket                  = aws_s3_bucket.tfstate.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Optional if you use the classic lock (skip when use_lockfile = true)
resource "aws_dynamodb_table" "locks" {
  name         = "terraform-locks"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"                    # MUST be exactly "LockID"
  attribute {
    name = "LockID"
    type = "S"
  }
}

Apply that once with local state, then add the backend "s3" block to your real root and run terraform init. If you had existing local state, init detects the new backend and offers to migrate it into S3:

terraform init -migrate-state
# Terraform prompts:
#   Do you want to copy existing state to the new backend? -> yes

Either way the ordering is the invariant: state bucket (+ table) exists → backend points at it → everything else.

Reading another stack’s outputs: terraform_remote_state

Once state lives in S3, one stack can read another’s outputs with the terraform_remote_state data source — the clean way for, say, an app stack to consume the network stack’s vpc_id without hardcoding it:

data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    bucket = "kloudvin-tfstate-111122223333"
    key    = "prod/network/terraform.tfstate"
    region = "ap-south-1"
  }
}

resource "aws_instance" "app" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
  # ...
}

Only values the network stack explicitly declares as output are visible here — remote state exposes outputs, not internals — and the reader needs read access to that state object. At scale you often prefer looser coupling (SSM Parameter Store, data sources that look the resource up by tag), a trade-off the remote state at scale lesson works through.

Hands-on: build it with Terraform

Now the full walkthrough. You will bootstrap a state bucket and lock table, write the starter files, authenticate via an SSO profile, and run init → plan → apply to create a VPC whose state lives in S3 — then verify with the aws CLI and destroy. Everything here is free-tier-friendly; the cleanup step removes it all.

⚠️ Real cloud spend. A plain VPC is free; the state S3 bucket costs a fraction of a rupee for a tiny object, and a PAY_PER_REQUEST DynamoDB table costs effectively nothing at this volume. The final destroy + bucket/table cleanup remove everything. Run in your own account.

Step 0 — Prerequisites. Terraform ≥ 1.6 and the AWS CLI v2 installed, and you’re authenticated:

terraform version                 # expect Terraform v1.6+ (OpenTofu 1.6+ works identically)
aws sso login --profile kloudvin-dev
export AWS_PROFILE=kloudvin-dev
aws sts get-caller-identity       # confirm the account + identity Terraform will use

Step 1 — Bootstrap the state store (the chicken-and-egg fix; run once). Pick a globally-unique bucket name — suffixing your account id is a reliable trick:

ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
BUCKET="kloudvin-tfstate-$ACCOUNT"
REGION="ap-south-1"

# Create the bucket (note: outside us-east-1 you MUST pass a LocationConstraint)
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
  --create-bucket-configuration LocationConstraint="$REGION"

# Version it (recover a clobbered state), encrypt it, and block public access
aws s3api put-bucket-versioning --bucket "$BUCKET" \
  --versioning-configuration Status=Enabled
aws s3api put-bucket-encryption --bucket "$BUCKET" \
  --server-side-encryption-configuration \
  '{"Rules":[{"ApplyServerSideEncryptionByDefault":{"SSEAlgorithm":"AES256"}}]}'
aws s3api put-public-access-block --bucket "$BUCKET" \
  --public-access-block-configuration \
  BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true

# Optional: a DynamoDB lock table (skip if you'll use use_lockfile = true)
aws dynamodb create-table --table-name terraform-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST --region "$REGION"

echo "State bucket: $BUCKET"   # note this — you'll paste it into the backend block

Step 2 — Write the starter files. Five files in an empty directory. versions.tf (provider + backend — paste your bucket name; this example uses the modern S3-native lock):

# versions.tf
terraform {
  required_version = ">= 1.6"

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

  backend "s3" {
    bucket       = "kloudvin-tfstate-XXXXXXXXXXXX"  # <-- your $BUCKET from Step 1
    key          = "demo/getting-started/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    use_lockfile = true    # S3-native locking (TF 1.10+); or use dynamodb_table = "terraform-locks"
  }
}
# providers.tf
provider "aws" {
  region = var.aws_region

  default_tags {
    tags = {
      ManagedBy   = "terraform"
      Environment = var.environment
      Project     = "kloudvin"
      Lesson      = "aws-getting-started"
    }
  }
}
# variables.tf
variable "aws_region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "ap-south-1"
}

variable "environment" {
  description = "Environment tag"
  type        = string
  default     = "demo"
}

variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.42.0.0/16"
}
# main.tf  — your first managed resource
resource "aws_vpc" "demo" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = {
    Name = "vpc-tf-getting-started"   # merges over default_tags
  }
}
# outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.demo.id
}

output "vpc_arn" {
  description = "ARN of the created VPC"
  value       = aws_vpc.demo.arn
}

Step 3 — terraform init. This downloads the aws provider and initialises the backend against your bucket:

terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.x.x...

Terraform has been successfully initialized!

If you had existing local state, you’d add -migrate-state and confirm the copy. A fresh directory just wires the backend.

Step 4 — terraform plan. Preview the change. One resource to add (output trimmed — a real VPC plan shows many computed attributes):

terraform plan
Terraform will perform the following actions:

  # aws_vpc.demo will be created
  + resource "aws_vpc" "demo" {
      + arn                  = (known after apply)
      + cidr_block           = "10.42.0.0/16"
      + enable_dns_hostnames = true
      + enable_dns_support   = true
      + id                   = (known after apply)
      + tags                 = { "Name" = "vpc-tf-getting-started" }
      + tags_all             = {
          + "Environment" = "demo"
          + "Lesson"      = "aws-getting-started"
          + "ManagedBy"   = "terraform"
          + "Name"        = "vpc-tf-getting-started"
          + "Project"     = "kloudvin"
        }
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Note tags_all — that’s your default_tags merged with the resource’s own Name. If instead you see NoCredentialProviders, no chain link resolved (Step 0); if you see a region error, set aws_region/AWS_REGION.

Step 5 — terraform apply. Create it for real. Terraform takes the state lock, applies, writes state to S3, releases the lock:

terraform apply       # review, type: yes
aws_vpc.demo: Creating...
aws_vpc.demo: Creation complete after 2s [id=vpc-0a1b2c3d4e5f67890]

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Outputs:
vpc_id = "vpc-0a1b2c3d4e5f67890"

Step 6 — Verify in AWS with the aws CLI. Confirm the VPC exists and that the state is really an object in your bucket:

# The VPC Terraform created (found by its default_tags)
aws ec2 describe-vpcs --filters "Name=tag:ManagedBy,Values=terraform" \
  --query "Vpcs[].{id:VpcId,cidr:CidrBlock,name:Tags[?Key=='Name']|[0].Value}" --output table

# The state object really lives in S3
aws s3 ls "s3://kloudvin-tfstate-$ACCOUNT/demo/getting-started/"
# -> terraform.tfstate

# When no apply is running, no lock is held (S3-native lock leaves a .tflock only during a write)
aws s3 ls "s3://kloudvin-tfstate-$ACCOUNT/demo/getting-started/" | grep -c tflock   # -> 0

Seeing terraform.tfstate listed in S3 is the whole point: your state is not on your laptop — it’s a locked, shared, encrypted, versioned object in S3.

Step 7 — Destroy and clean up (⚠️ removes the resources):

terraform destroy      # type: yes — removes the VPC
Plan: 0 to add, 0 to change, 1 to destroy.
...
Destroy complete! Resources: 1 destroyed.

Then remove the state store itself when you’re done with the lesson entirely (this deletes your state object too):

aws s3 rb "s3://kloudvin-tfstate-$ACCOUNT" --force     # empty + delete the bucket
aws dynamodb delete-table --table-name terraform-locks --region ap-south-1

The steps mapped to what each one proves:

Step Command What it proves
1 aws s3api create-bucket + dynamodb create-table Bootstrap breaks the chicken-and-egg
3 terraform init Provider install + backend wiring in one
4 terraform plan Declarative preview; tags_all shows default_tags
5 terraform apply Real resource created; state lock taken/released
6 aws s3 ls State genuinely lives in the remote bucket
7 terraform destroy + s3 rb Clean teardown, no lingering spend

Variables, outputs & making it reusable

The starter hardcodes almost nothing already, but three patterns turn it from a demo into something you’d actually reuse. First, partial backend configuration — you should not hardcode the bucket and key in versions.tf if the same code deploys to several environments. Leave the values out and supply them at init time:

# versions.tf — partial backend (values supplied at init)
terraform {
  backend "s3" {
    encrypt      = true
    use_lockfile = true
  }
}
# One .tfbackend file per environment
terraform init -backend-config=prod.tfbackend
# prod.tfbackend
bucket = "kloudvin-tfstate-111122223333"
key    = "prod/network/terraform.tfstate"
region = "ap-south-1"

This keeps one set of .tf files and swaps only the backend target per environment — the pattern the remote state at scale lesson builds on. Second, for_each to create many resources from a map — the leap from one VPC to a parameterised set of subnets:

variable "subnets" {
  description = "Map of subnet name => CIDR within the VPC"
  type        = map(string)
  default = {
    "public-a"  = "10.42.1.0/24"
    "public-b"  = "10.42.2.0/24"
    "private-a" = "10.42.10.0/24"
  }
}

resource "aws_subnet" "these" {
  for_each   = var.subnets
  vpc_id     = aws_vpc.demo.id
  cidr_block = each.value
  tags       = { Name = each.key }
}

Third, for real AWS infrastructure you will often reach for community modules from the registry rather than rolling your own. When to use each:

Option Example Use when
Roll your own resources aws_vpc, aws_subnet Simple, few resources, full control
terraform-aws-modules/* terraform-aws-modules/vpc/aws Battle-tested, opinionated building blocks (the de-facto standard)
Other community modules cloudposse/* A solved problem you don’t want to re-solve
A private module registry your org’s modules Standardising patterns across teams

The classic example — an entire production-grade VPC in a dozen lines via the community module instead of dozens of aws_* resources:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "kloudvin-prod"
  cidr = "10.42.0.0/16"

  azs             = ["ap-south-1a", "ap-south-1b"]
  private_subnets = ["10.42.10.0/24", "10.42.11.0/24"]
  public_subnets  = ["10.42.1.0/24", "10.42.2.0/24"]

  enable_nat_gateway = true   # ⚠️ a NAT gateway is the one thing here that costs real money
}

The inputs your reusable root should expose, so it’s environment-agnostic:

Input variable Type Why parameterise it
aws_region string Different region per environment
environment string Drives tags and naming
vpc_cidr / subnets string / map Address space per environment
default_tags map(string) Org-wide tag policy
backend values via -backend-config State target per environment

Common mistakes and troubleshooting

The failure modes are predictable and almost all live in the three foundations — provider/region config, authentication/IAM, and the backend. Scan the table, then read the detail for the ones that bite hardest.

# Symptom Root cause Confirm Fix
1 NoCredentialProviders: no valid providers in chain No credential-chain link resolved aws sts get-caller-identity also fails aws sso login / export AWS_PROFILE / set keys or a role
2 no valid credential sources … region / The region must be set No region resolved Provider has no region, AWS_REGION unset Set region in the provider or AWS_REGION
3 AccessDenied … not authorized to perform: <action> IAM identity lacks the action 403 naming the action + ARN Add exactly that action at that resource to the policy
4 ExpiredToken / The security token … is expired SSO/STS session lapsed Worked earlier, now 401/403 aws sso login again (re-mint temp creds)
5 Error acquiring the state lock Lock held by a crashed/parallel run Lock info shows ID + Who/Created Ensure no live apply, then terraform force-unlock <ID>
6 Error: Failed to get existing workspaces … NoSuchBucket Backend bucket missing/typo’d Bucket not created or wrong name Bootstrap the bucket; fix bucket; terraform init
7 Backend init: AccessDenied on s3:ListBucket/GetObject Identity lacks state-bucket IAM Have resource perms, not state perms Add the S3 (+ DynamoDB) state policy shown above
8 plan wants to create everything that exists Backend points at empty/wrong key Full-create plan instead of no-op Fix key/backend target; do not apply
9 BucketAlreadyExists / …OwnedByYou at bootstrap Bucket name not globally unique 409 at create Choose a unique name (suffix the account id)
10 ValidationException … schema on lock DynamoDB hash key not LockID Table exists but locking misbehaves Recreate the table with hash key exactly LockID (String)
11 Error: creating … you are not authorized … sts:AssumeRole Assume-role trust/perm gap 403 on AssumeRole Fix the target role’s trust policy; add sts:AssumeRole
12 OIDC login fails in CI Missing id-token: write or bad sub GitHub token/claim mismatch Add the permission; match the role’s sub condition exactly
13 Resource created in the wrong region Provider/alias region mismatch Resource shows up in another region Set the right region/alias; provider = on the resource
14 Error inspecting states … dynamodb_table is deprecated Using the classic lock on TF 1.11+ Warning at init/plan Add use_lockfile = true; drop dynamodb_table once all collaborators are on ≥ 1.10

The five that cause the most lost hours, expanded:

1. NoCredentialProviders. No credential-chain link resolved — usually an expired SSO session or an unset AWS_PROFILE. Run aws sts get-caller-identity first; if that fails, so will Terraform. Fix the CLI (aws sso login, export AWS_PROFILE), then re-run. The number-one first-run AWS error.

3 & 7. AccessDenied — resource vs state. Two flavours. The common one lacks a resource action (ec2:CreateVpc) — the error names the exact action and ARN, so add precisely that, never "*". The subtle one is a backend AccessDenied: the identity has resource permissions but not the state permissions (s3:GetObject/PutObject/ListBucket on the bucket, plus dynamodb:*Item on the table for the classic lock). Grant the state policy shown earlier.

5. Error acquiring the state lock. A lock (a DynamoDB LockID item or an S3 .tflock object) left held by a crashed run — or a colleague applying right now. Confirm no apply is actually running (the lock info shows who and when), then terraform force-unlock <ID>. Never automate it — force-unlocking a live apply corrupts state. Full recovery mechanics are in the backends deep dive.

8. plan proposes creating resources that already exist. Almost always the backend points at the wrong or empty key/bucket (a typo, wrong -backend-config, or an accidental -reconfigure). Terraform read empty state and thinks nothing exists. Do not apply — you’ll create duplicates or collide on names. Re-point the backend and re-init.

13. Wrong region / alias. A resource in the wrong region means either the provider’s region isn’t what you think (an AWS_REGION shadowing the block) or you forgot provider = aws.<alias> on a region-pinned resource (the ACM-for-CloudFront trap). Terraform binds each resource to the provider that made it, so moving regions means destroy-and-recreate — set the region correctly before the first apply.

Cost, cleanup & production notes

The cost of this foundation is essentially free — the only paid things are the state store and any downstream resources, and the state store is negligible:

Item What you pay for Rough cost Notes
VPC (+ subnets, IGW, route tables) Nothing ₹0 These are free; NAT gateway is not
State S3 bucket Storage + requests a fraction of ₹1/month A tiny object + light traffic
DynamoDB lock table (PAY_PER_REQUEST) Per-request effectively ₹0 A handful of writes per apply
S3-native lockfile S3 requests only effectively ₹0 No table at all
IAM roles / OIDC provider Nothing ₹0 IAM objects are free
The resources you go on to build Per-service varies The real bill is downstream (NAT, EC2, RDS…)

Cleanup is terraform destroy for what a root created, then emptying and deleting the state bucket (aws s3 rb --force) and the lock table when you’re finished. Because state is remote, don’t just delete local files — destroy through Terraform so state stays consistent.

Production hardening notes — the discipline that keeps this foundation safe at scale:

Practice Why it matters How
OIDC over static keys in CI No credential to leak or rotate configure-aws-credentials + a federated role; never a committed key
Least-privilege IAM + narrow scope Limit blast radius Customer-managed policy of exact actions; permissions boundary on delegated roles
Lock down the state bucket State holds secrets in plaintext Block public access, encrypt (SSE-KMS), bucket policy, optionally a VPC endpoint
Versioning + (optional) MFA delete Recover a clobbered state Turn on S3 versioning on the state bucket
One state key per stack; isolate envs Contain lock scope and failure dev/ vs prod/ prefixes or separate buckets/accounts
Pin provider + commit the lock file Reproducible plans ~> pin in required_providers; commit .terraform.lock.hcl
default_tags for governance Cost allocation + ownership Provider-level default_tags; enforce with SCP/Config
Detect drift Catch out-of-band changes Scheduled plan -detailed-exitcode in CI

On state security specifically: the state file records resource attributes including secrets (a generated password, an RDS connection string) in plaintext. That is exactly why the state bucket deserves the same protection as a secrets store — block public access, SSE-KMS encryption, a tight bucket policy, versioning, and ideally a private S3 endpoint. Treat kloudvin-tfstate-* as tier-0 infrastructure.

Cheat-sheet

The whole foundation on one screen.

Provider + backend skeleton:

terraform {
  required_version = ">= 1.6"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.0" }
  }
  backend "s3" {
    bucket       = "kloudvin-tfstate-<account-id>"
    key          = "env/stack/terraform.tfstate"
    region       = "ap-south-1"
    encrypt      = true
    use_lockfile = true            # or dynamodb_table = "terraform-locks"
  }
}

provider "aws" {
  region = var.aws_region
  default_tags { tags = { ManagedBy = "terraform", Environment = var.environment } }
}

Auth method → how to turn it on:

Method Turn on with
Static keys AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (+ AWS_SESSION_TOKEN for temp)
Profile / SSO aws sso login --profile P + AWS_PROFILE=P
Assume-role assume_role { role_arn = "…" } in the provider
Instance/task role Attach the role to the EC2/ECS/EKS compute (nothing in .tf)
OIDC (GitHub) aws-actions/configure-aws-credentials + id-token: write + federated role

Commands you’ll run constantly:

Command Does
aws sso login --profile P / export AWS_PROFILE=P Authenticate; select account
aws sts get-caller-identity Confirm who Terraform will act as
terraform init (-migrate-state, -backend-config=f.tfbackend) Install providers; wire/migrate backend
terraform plan / apply / destroy Preview / make / remove changes
terraform force-unlock <ID> Break a stale state lock (carefully)
aws s3 ls s3://<bucket>/<key-prefix>/ Confirm state is in the bucket

IAM quick map:

Need Grant
Manage resources The service actions this stack uses (least-privilege)
Read/write state in S3 s3:GetObject, s3:PutObject, s3:ListBucket on the bucket
Classic DynamoDB lock dynamodb:GetItem, PutItem, DeleteItem on the table
Assume a cross-account role sts:AssumeRole + the target role’s trust policy

Interview and exam questions

1. What does the aws provider require to initialise, and what’s the region gotcha? Unlike some providers it has no mandatory block, but it must resolve a region — from the provider region argument, AWS_REGION/AWS_DEFAULT_REGION, or a profile — or every API call fails. Credentials must also resolve via the credential chain. Missing region → The region must be set; missing creds → NoCredentialProviders.

2. Describe the AWS credential chain and its order. The provider searches, in order: (1) static creds in the provider block, (2) environment variables (AWS_ACCESS_KEY_ID…), (3) shared credentials/config files and profiles (incl. SSO), (4) assume-role / web identity, (5) container credentials (ECS/EKS), (6) EC2 instance profile via IMDS. The first link that resolves wins; confirm the winner with aws sts get-caller-identity.

3. Why avoid long-lived IAM access keys, and what do you use instead? They never expire, grant standing access, and a leak is an account breach with no natural revocation. Prefer temporary credentials from short-lived sources: an SSO profile locally, an instance/task role on AWS compute, and OIDC federation in CI — none of which stores a permanent secret.

4. How does OIDC authenticate GitHub Actions to AWS with no stored secret? You register GitHub’s OIDC provider in IAM and create a role whose trust policy trusts token.actions.githubusercontent.com with a sub condition pinning the repo/branch. In the workflow (id-token: write), aws-actions/configure-aws-credentials exchanges the short-lived OIDC token for STS credentials via AssumeRoleWithWebIdentity. Nothing is stored or rotated.

5. Write a backend "s3" block and name each argument. bucket (the S3 bucket), key (the object path = this stack’s state), region (the bucket’s region), encrypt = true (server-side encrypt the state object), and a lock: either dynamodb_table (classic) or use_lockfile = true (S3-native, TF 1.10+).

6. Compare DynamoDB locking with the S3-native lockfile. DynamoDB uses a table with a LockID item to coordinate writes — an extra resource, needs dynamodb:*Item IAM. S3-native locking (use_lockfile = true, Terraform 1.10+) uses an S3 conditional-write .tflock object — no table, no extra IAM beyond S3. dynamodb_table was deprecated in Terraform 1.11; new projects should use use_lockfile.

7. Explain the chicken-and-egg bootstrap and how you solve it. The backend needs an S3 bucket (and maybe a DynamoDB table) to hold state, but you’d manage those with Terraform, which needs the backend — circular. Break it by creating the bucket + table first (a tiny local-state Terraform root, or the AWS CLI), then add the backend "s3" block and run terraform init (with -migrate-state if you had local state).

8. What is default_tags, and how do resource tags interact with it? A provider-level block whose tags are applied to every taggable resource the provider creates, so governance tags are written once. A resource’s own tags merge on top and override on a key conflict; the merged result appears as tags_all in plan/state.

9. When do you need a provider alias, and how do you use it? When a resource must live in a different region (e.g. an ACM cert for CloudFront in us-east-1) or you manage multiple accounts. Declare a second provider "aws" with alias = "x" (and a different region and/or assume_role), then point a resource with provider = aws.x or a module with providers = { aws = aws.x }.

10. What IAM does a Terraform role need beyond the resource actions? Its state permissions: s3:GetObject/PutObject/ListBucket on the state bucket, and — if using the classic lock — dynamodb:GetItem/PutItem/DeleteItem on the lock table. Teams grant resource perms, then hit a backend AccessDenied because the state policy is missing.

11. (Terraform Associate style) You run terraform plan and it proposes creating resources that already exist in AWS. What happened? The backend initialised against empty or wrong state — a mistyped key/bucket, wrong -backend-config, or an accidental -reconfigure — so Terraform read no state and thinks nothing exists. Do not apply (you’d create duplicates or hit name collisions). Re-point the backend at the correct object and re-init.

12. (Terraform Associate style) A teammate’s crashed apply left the state locked. What do you do? Read the lock info (ID, who, when), confirm no apply is genuinely still running, then terraform force-unlock <ID>. Never force-unlock blindly or from automation — breaking a live lock corrupts state.

These map cleanly onto the certification landscape:

Question theme Primary cert Objective area
Provider config, backends, state locking HashiCorp Terraform Associate (003) Providers; backends & state
Credential chain, profiles, OIDC Terraform Associate + AWS SAA-C03 Automation identity; secure IaC
IAM roles, assume-role, least-privilege AWS SAA-C03 / Security Specialty IAM & access management
State security, S3/KMS hardening AWS Security Specialty Data protection

Key takeaways

TerraformawsAWSAuthenticationOIDCassume-roleIAMremote-stateS3DynamoDBbackendcredential-chaindefault_tagsIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments