What terraform init does before plan and apply can run

Terraform Infrastructure as Code DevOps Best Practices

What is terraform init?

terraform init prepares a working directory so every other Terraform command has something to run against. It installs the provider plugins and modules your Terraform configuration references, configures the backend that will store state, and writes the dependency lock file that pins exactly what it resolved.

Nothing else in the Terraform workflow, not terraform plan, not terraform apply, works until this has run at least once.

Basic terraform init syntax

The general form is terraform init [options], run with no other arguments against a directory that already contains your .tf files. Here is a minimal configuration:

terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
resource "aws_s3_bucket" "assets" {
bucket = "acme-web-assets-prod"
}

Run terraform init against that directory, and you'll see something similar to this:

$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.60.0...
- Installed hashicorp/aws v5.60.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!

Notice there's no separate provider "aws" {} block here, only required_providers. A provider block is needed once you have to set something like a region or credentials, but init only needs the version constraint to resolve and download the plugin.

Two things have now been added to the Terraform working directory that weren't in there before:

  1. A .terraform directory holding the downloaded provider plugin.
  2. A .terraform.lock.hcl file recording exactly which version it picked.

Both are safe to inspect; only the lock file is meant to be committed to version control.

(By the way, everything in this article applies identically to OpenTofu. Just swap terraform for tofu in every command, and the behavior, flags, and file layout carry over unchanged.)

There are a handful of terraform init options that most engineers will actually use:

  • -upgrade ignores the existing dependency lock file and re-resolves provider and module versions against your version constraints.
  • -reconfigure discards the previously recorded backend settings and initializes fresh, without attempting to migrate any existing state.
  • -migrate-state does the opposite: it keeps state, copying it into whatever new backend you've just pointed the configuration at.
  • -backend-config=KEY=VALUE (or -backend-config=path/to/file) supplies backend settings at init time rather than hardcoding them into the backend block itself.
  • -backend=false skips backend initialization entirely, making it useful when you only need modules and providers refreshed and already know the backend is fine.
  • -lockfile=readonly verifies checksums against the committed lock file without writing changes to it, which is the mode most CI pipelines should run in.

What terraform init actually does

Four things happen, in this order, every time you run terraform init.

Terraform initializes the backend

If the root module's terraform block doesn't declare one, Terraform falls back to the default local backend and stores the Terraform state file, terraform.tfstate, on disk in the working directory.

If a backend is declared (an S3 bucket, a GCS bucket on Google Cloud Platform, an HCP Terraform workspace, previously branded Terraform Cloud), terraform init connects to that backend storage location and, for backends that support it, sets up the locking infrastructure that later terraform plan and terraform apply operations rely on to avoid two processes writing state at once.

Changing the backend block later forces a decision between -migrate-state and -reconfigure covered under common mistakes below.

Point terraform init at an S3 backend with use_lockfile = true (Terraform 1.10 and later) and it verifies Terraform can write a native lock object alongside your state; older configurations instead pair S3 with a separate DynamoDB table for the same purpose.

Target it at GCS or Azure Blob Storage and it will confirm access to that provider's own native locking primitive instead.

The local backend still locks, just only at the operating-system level on whichever machine happens to be running the command, which is exactly why it stops being enough once a second person or a CI runner needs the same state.

Child module installation

Terraform searches the configuration for every module block, local or remote.

  • Local modules referenced by a relative source path are used directly from wherever they live in your repository; Terraform doesn't copy them anywhere.
  • Remote modules – such as a Git repository, a Terraform Registry module, or an S3 path – get downloaded into .terraform/modules, alongside a modules.json manifest that records where each one came from.

Change a module block's source or version constraint, and Terraform won't pick that up on the next terraform plan. You have to run terraform init again (or the narrower terraform get, a close alternative covered below) first.

None of this assumes the public Terraform Registry is reachable, either. Air-gapped environments and organizations with a stricter supply-chain policy commonly point terraform init at a private provider mirror instead, configured once in the CLI configuration file's provider_installation block rather than per project.

init doesn't care where a provider physically comes from; it only cares that the source it's told to check resolves to something matching the version constraint.

Provider installation

Terraform walks your configuration for every required_providers entry declared inside the terraform block, direct or referenced through a module, and resolves each against its version constraint.

This step only needs required_providers; a separate provider "aws" {} configuration block with credentials or a region isn't required for init to succeed, only for later operations that actually call the provider.

For providers published to the public Terraform Registry, or to a private registry you've configured, it downloads the matching plugin binary and caches it under .terraform/providers, organized by registry host, namespace, provider name, and version.

If you're not pinning provider versions, Terraform grabs the latest one that satisfies whatever constraint exists or, with no constraint at all, whatever the newest release happens to be. Make sure that you pin your versions

The dependency lock file

.terraform.lock.hcl records the exact provider versions and package checksums Terraform selected, keyed by platform.

Its entire purpose is making sure a second run, on a different machine, on a different day, resolves to the identical provider build rather than whatever happens to be newest at that moment. Commit it.

It is the one time this whole sequence produces something you'll actually see in a terraform plan: without a correctly initialized provider, Terraform can't even parse a resource block enough to schedule it, so aws_s3_bucket.assets never becomes a plannable resource instance until the provider behind it is sitting in .terraform/providers.

An example of terraform init in action

Say you've just cloned a repository with a shared backend, a local module, and a pinned provider version.

# main.tf
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.60"
}
}
backend "s3" {
bucket = "acme-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
module "network" {
source = "./modules/network"
vpc_cidr = "10.20.0.0/16"
}

Running terraform init here walks the full sequence.

  1. It connects to the acme-terraform-state S3 bucket and confirms it can read and lock state there.
  2. It recognizes module.network as a local module and uses it in place
  3. It resolves hashicorp/aws against the ~> 5.60 constraint, downloading the plugin and writing the lock file.
$ terraform init
Initializing the backend...
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
Initializing modules...
- network in modules/network
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.60"...
- Installing hashicorp/aws v5.60.0...
- Installed hashicorp/aws v5.60.0 (signed by HashiCorp)
Terraform has been successfully initialized!

Take note of the order: backend first, then modules, then providers. Without that sequence a broken backend connection fails init before Terraform ever gets to checking whether your provider versions are even resolvable.

A week later, a teammate bumps the provider constraint to ~> 6.0 in a pull request and merges it. Your next terraform init (or your CI pipeline's) fails because the existing .terraform.lock.hcl still has 5.60.x recorded, and ~> 6.0 doesn't overlap with it.

Terraform won't pick a new version on your behalf; it will stop and ask you to be explicit. Running terraform init -upgrade re-resolves against the new constraint, downloads hashicorp/aws 6.x, and rewrites the lock file, which you can then commit alongside the version bump so everyone else's next plain terraform init resolves the same way.

Common mistakes users make with terraform init

Changing the backend block and expecting the next init to just work

Update the bucket name, the key, or the backend type, and a plain terraform init will stop and tell you the backend configuration changed rather than silently picking a side.

You have to say whether you want the existing state copied into the new backend (-migrate-state) or want Terraform to disregard the existing configuration and start over (-reconfigure).

Skipping that decision can produce a prompt that confuses engineers the first time they encounter a backend block.

Assuming a lock file works across every platform automatically

The dependency lock file records checksums per platform and, by default, terraform init only records the checksums for the platform it's currently running on.

Commit a lock file generated on a developer's Mac, and a Linux CI runner can fail with a checksum error because the Linux package hashes were never recorded in the first place.

The fix is terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 (naming every platform your team and your pipelines actually run on) before committing.

That command reaches out to the registry to fetch checksums for platforms other than the one you're running on, so it needs network access even though it won't actually install anything for those other platforms locally.

Hardcoding backend credentials into a versioned backend block

A bucket name and region are fine to commit. An access key, or anything else that grants write access to your state, isn't, and backend blocks can't reference variables or secrets managers directly because Terraform has to resolve the backend before it can evaluate anything else in the configuration.

Engineers use -backend-config for this purpose: to keep the stable values in the block and inject the sensitive ones at init time from CI secrets or environment variables, so nothing sensitive ever lands in a file that gets checked into version control.

Reaching for -upgrade out of habit

It's tempting to run terraform init -upgrade whenever init complains about anything, but it ignores the lock file entirely and pulls the newest version matching your constraints.

Do that in the middle of a normal workflow, and you can end up running a different provider version than the rest of your team unintentionally.

Treat -upgrade as a deliberate action taken alongside a version bump, not a routine troubleshooting step.

Expecting init to validate your configuration

terraform init confirms it can install what your configuration references and connect to the backend it names. It does not check whether your resource arguments are valid, whether references resolve, or whether types match; that's what terraform validate and terraform plan are for.

A successful init tells you Terraform is ready to reason about your configuration, not that the configuration itself is correct.

Alternatives to terraform init

The closest thing to an alternative to terraform init is terraform get, and it's important to be precise about the difference, especially as module installation, the only thing get does, is one of the steps on init.

terraform get (or terraform get -update to pick up newer versions of already-installed modules) refreshes only the modules a configuration references. It doesn't touch the backend, and it doesn't install or upgrade providers.

$ terraform get -update
- network in modules/network

In practice, almost nobody uses get on its own outside of active module development, where you're iterating on a remote terraform module's source and just want Terraform re-downloading modules without re-touching the backend or providers.

Everywhere else, init already covers it. The other flags covered above (-upgrade, -reconfigure, -migrate-state) aren't separate commands either; they're behavior toggles on the same init, not genuinely different tools for genuinely different jobs.

Stategraph's perspective on terraform init

Backend initialization is the most important step and the one to remember, because it's where the infrastructure for state locking actually gets provisioned.

For a remote backend that supports it, terraform init sets the backend up so that any later plan or apply acquires a lock covering the entire state file before it writes anything, regardless of how much of that state the operation actually impacts.

That default is invisible for a single engineer working alone, but it can become the bottleneck once an organization is running many concurrent changes against shared state: an engineer rotating a security group rule and a pipeline resizing an autoscaling group have no overlap at all, and they still queue behind the same lock.

Stategraph doesn't change what terraform init does, and it doesn't replace it; you still run the same command against the same configuration. The change is in what happens once state exists and more than one plan and apply wants to touch it.

Stategraph stores the infrastructure dependency graph in a database and operates on the specific subgraph a change affects, which is what makes resource-level locking possible instead of a single lock over the whole file, and lets non-overlapping changes run in parallel rather than serialize.

Changes that span more than one state get the same treatment through stategraph tf mtx, which plans across every state in the change as a single atomic transaction before you commit it with stategraph tf apply.

You won't necessarily encounter any of that while you're running terraform init itself, but it will affect how much of your team's day gets spent waiting on locks instead of shipping changes.

If backend initialization and state locking are already a familiar source of friction on your team, try Stategraph free and see the same plan and apply commands running against a graph instead of a flat file.

Related Terraform terms