Set up the AWS provider, create your first resource, move state, separate environments, and deploy through CI.
The Terraform AWS provider lets Terraform create and manage resources through the AWS APIs. In this guide, we use it to add a receipt bucket to an orders service and take that change from a local configuration to a production deployment.
Along the way, we will configure AWS access, move Terraform state to a shared backend, separate development from production, and set up a reviewed apply through CI.
If you have not committed to Terraform yet, the final section introduces Encore as an alternative path to deploying on AWS without learning the AWS provider and operating a separate infrastructure workflow.
When working with a Terraform project that targets AWS, a minimal implementation pins the Terraform and provider versions, selects a region, and sets any tags that should apply to every resource. For the orders service, that starting point looks like this:
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Application = "orders"
ManagedBy = "terraform"
}
}
}
variable "aws_region" {
type = string
default = "eu-west-1"
}
Run terraform init to download the provider and initialize the project, then commit the generated dependency lock file so local and CI runs resolve the same provider build. The constraint above allows updates within the current major version, while keeping major upgrades separate so changes to resource schemas or defaults can be reviewed properly.
Authentication stays outside the provider block. The AWS provider follows the standard credential chain, which means a developer can use an IAM Identity Center profile locally while CI uses workload identity to assume a deployment role:
aws sso login --profile engineering-dev
export AWS_PROFILE=engineering-dev
terraform plan
Adding access_key and secret_key to the provider block is unnecessary and makes those credentials easier to leak through shell history, CI configuration, or state. The Terraform configuration can identify the expected account and role, while the environment running Terraform supplies short-lived credentials.
If the same codebase can target more than one AWS account, provider aliases make that choice visible in the configuration instead of leaving it to whichever credentials happen to be active in the shell:
provider "aws" {
alias = "production"
region = "eu-west-1"
assume_role {
role_arn = "arn:aws:iam::123456789012:role/terraform-deploy"
}
}
Aliases are useful when one change genuinely spans accounts, but they should not become a shortcut for putting unrelated environments into the same root configuration. Every provider represented there can appear in the same plan, which widens the impact of a mistaken apply.
The orders service needs somewhere to retain generated receipts without exposing them publicly or losing the previous object when a receipt is replaced. With the provider configured, that requirement becomes an S3 bucket with public access blocked and versioning enabled:
variable "application" {
type = string
default = "orders"
}
variable "environment" {
type = string
default = "development"
}
resource "aws_s3_bucket" "receipts" {
bucket_prefix = "${var.application}-${var.environment}-receipts-"
}
resource "aws_s3_bucket_public_access_block" "receipts" {
bucket = aws_s3_bucket.receipts.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
resource "aws_s3_bucket_versioning" "receipts" {
bucket = aws_s3_bucket.receipts.id
versioning_configuration {
status = "Enabled"
}
}
output "receipts_bucket" {
value = aws_s3_bucket.receipts.bucket
}
The references to aws_s3_bucket.receipts.id tell Terraform to create the bucket before configuring public access and versioning. Resources without a dependency can be created in parallel.
Before applying the bucket, format and validate the configuration and save the proposed changes to a plan file. Applying that same file ensures Terraform executes the plan that was inspected rather than recalculating it after approval:
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform apply tfplan
For now, this plan and apply can run locally. We will move it into CI after the state and environment boundaries are in place.
Terraform state connects the addresses in the configuration to the resources that already exist in AWS. A local state file works while one person is experimenting, but it ties the project to one machine, cannot prevent concurrent applies, and may contain provider values that should not be broadly visible.
Before the orders infrastructure becomes shared, its state should move to a remote backend with encryption, restricted access, locking, version history, and audit logs. Locking prevents two applies from changing the same state at once, while version history provides a recovery path when a state change goes wrong.
For teams already operating in AWS, the S3 backend keeps state in the same cloud boundary and, with Terraform 1.10 or later, can use S3's native locking through use_lockfile:
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "orders/production.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
The state bucket is bootstrap infrastructure and must already exist before terraform init can use it. Enable bucket versioning for recovery, restrict access to the roles that run Terraform, and configure CloudTrail data events if the team needs object-level audit logs for reads and writes to the state.
Backend blocks cannot use ordinary Terraform variables, so values that differ by environment need to be supplied during initialization or kept in a small backend configuration file. Development and production should use separate state, since sharing one file gives either environment a path to change the other.
Use terraform state, import, and moved blocks when resources need to change address or come under Terraform management. Editing the JSON state file directly can break the mapping between configuration and AWS resources.
CLI workspaces can give one configuration several state instances, but they do not make differences in accounts, credentials, approvals, or module versions particularly visible. When development and production differ in those ways, a separate root for each environment makes the boundary clear in the repository:
infrastructure/ ├── modules/ │ └── orders-service/ │ ├── main.tf │ ├── variables.tf │ └── outputs.tf └── environments/ ├── development/ │ ├── main.tf │ └── backend.hcl └── production/ ├── main.tf └── backend.hcl
The module contains the resources that should behave consistently in both environments, while each root selects the account, module version, sizing, and dependencies for that deployment. Its backend and deployment role then make it clear which environment the directory is able to change.
Modules work best around a stable unit such as an application database, service network, or ECS service. The caller should only have to provide decisions it genuinely owns, while the module handles security and wiring defaults and returns the IDs or endpoints its consumers need.
Not every resource block needs that abstraction. A module introduces an interface, a release process, and an owner, and large modules with dozens of feature flags tend to produce broad plans that affect unrelated consumers. For a bucket used by only one service, keeping the resource near that service may be easier to understand than turning it into a general-purpose module.
In a pull-request workflow, the plan is the artifact being approved, not just a log line produced along the way. A typical pipeline therefore runs:
terraform init in the root being planned;terraform fmt -check, terraform validate, static checks, and policy tests;terraform plan after the change is trusted, with read access to the managed AWS resources;Plans can contain sensitive values and become stale when the target changes. Treat plan files as sensitive artifacts, give them short retention, and regenerate them when the base branch or state changes.
Read-only access to the managed resources is separate from access to the backend. A plan against shared state needs s3:ListBucket and s3:GetObject to read it, and when the backend uses use_lockfile = true, the workflow also needs s3:GetObject, s3:PutObject, and s3:DeleteObject on the .tflock object so it can acquire and release the lock. Those lock-capable credentials should remain in a protected workflow rather than being exposed to untrusted pull-request code. If a plan must run before the change is trusted, use an isolated backend and state instead.
For production, the apply job can use a separate role that is unavailable to pull-request jobs. AWS CloudTrail and the Terraform run history should connect each resource change to a pull request and an actor.
The checks around a reusable module cover different kinds of mistakes, so they work better as layers than as substitutes for one another:
Tests that create AWS resources cost money and can fail during cleanup. Run them in a dedicated account with budgets, quotas, and automatic cleanup rather than in a shared development account.
As the AWS estate grows, state boundaries work best when they follow ownership and lifecycle. One state file gives every run permission to change too much, while a state file for every resource creates unnecessary coordination. The orders service might own its bucket and queue together, for example, while the platform team keeps shared networking and account configuration in separate state.
Applies should normally run through CI using workload identity and role assumption rather than long-lived access keys. That gives the team a shared execution role, review history, and logs for each production change.
Shared modules also need explicit owners. Provider upgrades, service quotas, security findings, and breaking changes still land somewhere, even when application teams only see a small input surface.
If you are researching the Terraform AWS provider for a new application, you do not necessarily need to build the provider configuration, state backend, modules, and CI apply workflow described above. Encore provides another path: the application declares the infrastructure it needs, and Encore provisions and manages the corresponding resources in your AWS account.
For the orders service, the receipt bucket can be declared alongside the framework code that writes to it:
import { Bucket } from "encore.dev/storage/objects";
export const receipts = new Bucket("receipts", {
versioned: true,
});
That declaration provides local object storage during development, an isolated bucket for each preview environment, and S3 when the service is deployed to AWS. The Encore platform maintains the infrastructure state, IAM wiring, and provisioning workflow for supported application resources, while environment configuration still controls decisions such as regions, networking, sizing, and scaling.
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("todo", {
migrations: "./migrations",
});Managed PostgreSQL with migrations, pooling and IAM auth
Application code declares the resource; Encore provides it locally and maps it to the managed service selected for each AWS or GCP environment.
Learn more about how Encore automates infrastructure →
For application infrastructure, the difference is the workflow the team has to own:
For a new backend, this means the team can deploy to AWS without first learning Terraform, configuring the AWS provider, or operating a separate infrastructure pipeline. Teams that already have shared networks, organization policies, or other AWS foundations can leave them where they are, but Terraform is no longer a prerequisite for deploying the application itself.
See the migration guide for teams coming from Terraform.
Run in your terminal to get started locally.
Terraform AWS usually refers to using HashiCorp Terraform with the AWS provider. Terraform reads HCL configuration, compares it with state and the AWS APIs, then proposes and applies the changes required to create or update AWS resources.
The AWS provider uses the standard AWS credential chain, including environment variables, shared configuration files, IAM roles, workload identity, and AWS IAM Identity Center profiles. Avoid putting access keys directly in Terraform files.
For team use, store state in a remote backend with encryption, access control, versioning, and locking. HCP Terraform is one option. Teams also commonly use the S3 backend and enable state locking through the backend's supported locking mechanism.
No. Reuse modules, but give each environment its own root configuration, variables, credentials, and state. This reduces accidental cross-environment changes and lets production differ where it genuinely needs to.
Yes. Encore's open source Infra SDK lets applications declare services and infrastructure resources in code, then provisions the supported AWS infrastructure without requiring the team to maintain Terraform configuration, state, or a plan-and-apply workflow.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.