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.
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.
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.
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.
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.
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.
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.
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.
Run in your terminal to get started locally.
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.

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.
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.
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.
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.
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.
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.