Updated Nov 5, 20267 min read

Terraform Best Practices in 2026: State, Modules, Environments and CI

The practices that keep a Terraform codebase safe to change, and where application teams can skip Terraform for the infrastructure their services use.

Terraform is the most common way teams describe cloud infrastructure as code, and a small configuration with one state file and a handful of resources is easy to live with. It gets harder as the codebase grows into dozens of modules, several environments and a CI pipeline that holds admin credentials, where one unpinned provider or a manual change in the console can turn a routine terraform apply into a long afternoon.

The practices below are the ones that keep a large Terraform codebase safe to change. The last section covers application infrastructure, the databases, queues and buckets a backend service uses, where Encore (infrastructure from code) replaces the Terraform for that layer with declarations in the service code.

Terraform best practices at a glance

AreaPracticeWhy it matters
StateRemote backend with locking and versioningTwo applies cannot run at once, and a bad state write can be rolled back
VersionsPin Terraform and providers, commit .terraform.lock.hclEvery machine and CI run uses the same provider builds
ModulesSmall modules with clear inputs and outputsChanges stay reviewable and reusable
EnvironmentsOne state per environmentA staging change cannot touch production state
CIPlan on pull requests, apply from CI onlyEvery change is reviewed against a real plan
CredentialsOIDC roles instead of stored access keysNothing long-lived sits in CI secrets
SecretsKeep secret values out of stateState files are readable by anyone with backend access
DriftScheduled plan -detailed-exitcodeConsole edits show up before the next apply

Keep state remote, locked and versioned

Local state files get lost, overwritten and committed by accident, so the first practice is a remote backend. On AWS that is usually an S3 bucket with versioning turned on, which lets you restore an earlier state after a bad write. Starting with Terraform 1.10, the S3 backend can lock state with a lock file in the same bucket by setting use_lockfile = true, which replaces the separate DynamoDB table older setups needed:

terraform { backend "s3" { bucket = "acme-terraform-state" key = "prod/network/terraform.tfstate" region = "eu-central-1" encrypt = true use_lockfile = true } }

Turn on S3 bucket versioning, block public access on the bucket, and restrict who can read it, since state holds every attribute of every resource.

Pin versions and commit the lock file

Set required_version for Terraform and a version constraint for each provider in required_providers, then commit .terraform.lock.hcl so terraform init installs the same provider builds everywhere. Upgrade providers on purpose with terraform init -upgrade in a dedicated pull request, read the changelog, and review the plan it produces before merging.

Write small modules with clear boundaries

A module should do one thing, such as a VPC, a database or a service, take a short list of typed variables with validation blocks, and expose outputs the caller needs. Modules that wrap other modules three levels deep are hard to review because a one-line change in the inner module can change resources in every environment. Use moved blocks when you rename resources or split modules, so Terraform updates addresses in state instead of destroying and recreating, and import blocks to bring existing resources under management through a reviewed plan.

Give each environment its own state

Workspaces share one backend configuration and one set of credentials, which suits temporary copies of the same stack. For staging and production, separate root directories or separate backends give each environment its own state file, access rules and approval step, so a mistake in staging cannot write to production state. Keep the shared logic in modules and the per-environment values in small root configurations.

Plan in CI, apply from CI, and use short-lived credentials

Run terraform fmt -check, terraform validate and a linter such as TFLint on every pull request, then post the terraform plan output for review. Apply only from CI after merge, so nobody applies from a laptop with different local state or versions. For credentials, configure an OIDC identity provider in AWS IAM and let the pipeline assume a role per run, which GitHub documents for GitHub Actions, instead of storing access keys in CI secrets. Static analysis tools like Checkov catch public buckets and open security groups before they reach a plan.

Keep secrets out of state

Marking a variable or output sensitive = true hides it from plan output, but the value is still written to the state file. Prefer resources that generate or store the secret in a secret manager and pass only references around, and use ephemeral values and write-only arguments, added in Terraform 1.10 and 1.11, for providers that support them, so the value never lands in state.

Detect drift on a schedule

Someone will change a security group in the console during an incident. A scheduled CI job that runs terraform plan -detailed-exitcode exits with code 2 when the real infrastructure differs from the configuration, which turns drift into an alert instead of a surprise in the next unrelated apply.

Set up the Encore app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create orders --example=hello-world

Application infrastructure from service code with Encore

Encore

Encore is infrastructure from code for TypeScript and Go, with a platform that provisions what your services declare into your own AWS or GCP account. For the infrastructure a backend uses, such as Postgres databases, Pub/Sub topics, object storage buckets, cron jobs and secrets, the declaration lives in the service code, and encore run starts working local versions of it, so there is no module, state file or plan for that layer to keep in sync with the application.

The Infrastructure page in the Encore dashboard with a database's configuration panel open, showing fields for vCPUs, memory, storage, availability type, max connections and backups.
Each provisioned resource can be reviewed and its configuration edited per environment in the Encore dashboard. The screenshot shows a GCP Cloud SQL database.

A Go service that stores orders in Postgres and publishes an event for other services looks like this:

// orders/orders.go package orders import ( "context" "encore.dev/pubsub" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type OrderPlaced struct { OrderID int64 Total int64 } var Placed = pubsub.NewTopic[*OrderPlaced]("order-placed", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, }) type PlaceParams struct { Total int64 } type Order struct { ID int64 } //encore:api public method=POST path=/orders func Place(ctx context.Context, p *PlaceParams) (*Order, error) { var id int64 err := db.QueryRow(ctx, `INSERT INTO orders (total) VALUES ($1) RETURNING id`, p.Total).Scan(&id) if err != nil { return nil, err } if _, err := Placed.Publish(ctx, &OrderPlaced{OrderID: id, Total: p.Total}); err != nil { return nil, err } return &Order{ID: id}, nil }

The migration in orders/migrations/1_create_orders.up.sql creates the table:

CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, total BIGINT NOT NULL );

Encore applies the migration locally and on every deploy, and on AWS Encore provisions RDS for the database and SNS with SQS for the topic, runs the service on Fargate or EKS, and Encore provisions an IAM role for the service. Each environment gets the same resources from the same code, with production defaults such as Multi-AZ databases and dead-letter queues, and you adjust instance sizes in the infrastructure configuration rather than in a module variable. Teams moving an existing setup can follow the Terraform migration guide, and encore build docker produces an image if a service needs to run on infrastructure you manage yourself.

An AI coding agent working in the repository can add a database or a topic in the same change as the code that uses it, then check it with encore run, which is much easier to review than a code change paired with a module change in another repository.

Best fit: application teams who want each service's databases, queues and buckets to come from its code, provisioned in their own AWS or GCP account.

How to apply these practices

Start with state: a versioned, locked remote backend and one state per environment remove the most dangerous failure modes. Pin versions next, then move applies into CI with OIDC credentials and add a scheduled drift check. Keep Terraform for the shared foundation it is good at, such as accounts and organization-wide networking, and consider Encore for the application layer, where infrastructure changes as often as the code.

Frequently asked questions

What are the most important Terraform best practices?

Keep state in a remote backend with locking and versioning, pin Terraform and provider versions and commit the lock file, give each environment its own state, and run plan on pull requests and apply only from CI with short-lived credentials.

Should I use Terraform workspaces for staging and production?

Workspaces share one backend and one set of credentials, so they suit short-lived copies of the same configuration. For staging and production, separate directories or separate backends give each environment its own state, access and approval rules.

Do I still need DynamoDB for Terraform state locking on AWS?

Terraform 1.10 and later can lock state in the S3 backend itself by setting use_lockfile = true, which writes a lock file next to the state in the bucket, and DynamoDB-based locking is deprecated.

How do I give a backend service a database and a queue on AWS without a Terraform module?

With Encore, declare them in the service code, for example sqldb.NewDatabase and pubsub.NewTopic in Go. encore run starts Postgres and the queue locally, and on deploy Encore provisions RDS, SNS and SQS in your AWS account and provisions an IAM role for the service.

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders