Modules

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A module is a directory of Terraform files that is treated as one unit. Every configuration is already a module (the root module), and the module block turns other directories, repositories or registry packages into child modules that the root calls like functions: values go in as input variables, values come out as outputs, and the resources inside become part of the same plan. This page covers the consuming side: how a configuration calls modules, the address formats that source accepts, how versions are chosen, how modules are installed, how providers and meta-arguments reach them, and how to judge a module somebody else wrote. It targets Terraform 1.17 (released 2026-10-21) and OpenTofu 1.13. Writing and publishing your own modules is the subject of Module Development and Publishing.

The page follows Variables, Outputs and Locals, because a module is exactly a set of variables, resources and outputs behind a name, and Providers, which already described how provider configurations are handed to modules. It uses the count and for_each mechanics of Meta-Arguments: count, for_each and depends_on. The reorganization of resources into modules without destroying them is in Refactoring: moved and removed, and how modules are laid out per environment in Workspaces, Environments and Project Structure. For the section reading path see the section landing page.

The examples continue the Bookshelf scenario (catalog-api, reviews-api, web, PostgreSQL, Redis, a covers bucket, a network, DNS, secrets and IAM). The local examples use the kreuzwerker/docker and hashicorp/random providers so that they run on a laptop; the registry examples use the community terraform-aws-modules/vpc/aws module with placeholder values only. Nothing here contains credentials.

What a module is and why it exists

A Terraform module is a collection of .tf (or .tf.json) files kept together in a directory. There is no special syntax for declaring a module: any directory that contains configuration files is one. What makes it a module is how it is used.

  • The root module is the configuration in the working directory where you run terraform plan or terraform apply. Every configuration has exactly one.

  • A child module is a module that another module calls with a module block. The caller is its parent. A child can in turn call its own children, which makes a tree.

  • The same child can be called many times in one configuration (a different name each time), and several configurations can call the same child.

Modules exist for the same reasons functions do in a programming language:

  • Encapsulation. The caller does not need to know that a "database" is a subnet group, a parameter group, a security group and an instance. It sees a handful of inputs and a handful of outputs.

  • Reuse. One definition serves dev, staging and prod, or ten services that all need the same shape.

  • Consistency and policy. Naming, tagging, encryption and logging defaults live in one place.

  • Smaller blast radius for reviews. A change to a module is one reviewed, versioned change that many callers pick up deliberately.

Modules are not a way to hide things from Terraform. After installation the whole tree is flattened into one graph (see The Dependency Graph and the Plan), and every resource inside gets an address prefixed with the module path: a bucket named covers inside module.storage is module.storage.aws_s3_bucket.covers, and with for_each on the module it becomes module.storage["eu"].aws_s3_bucket.covers.

The module block

A child module is called with a module block whose label is a local name. The only required argument is source; every other argument is either a meta-argument or the value of one of the child’s input variables.

terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

module "database_credentials" {
  source = "./modules/credentials"

  name_prefix = "bookshelf-catalog"
  length      = 32
}

output "db_password" {
  value     = module.database_credentials.password
  sensitive = true
}

The label (database_credentials) is how the rest of the configuration refers to the module. If the child declares an output "password", the caller reads it as module.database_credentials.password. A child’s resources are never reachable from the parent directly: whatever the parent needs must be exported through an output. This is what makes a module an interface, and it is why a missing output is the most common reason to edit a module.

Several module blocks may share one source as long as their labels are unique; Terraform installs the code once and instantiates it per block.

Arguments of the module block

Argument Meaning

source

Required. Where the code comes from. It must be a literal string, except that input variables declared with const = true, and locals built from them, may be referenced (see below).

version

A version constraint. It applies only to modules installed from a registry. Local paths always share the caller’s version, and Git or archive sources are pinned inside the address instead (for example with ref).

count

Create several instances from a number. Mutually exclusive with for_each.

for_each

Create one instance per element of a map or a set of strings. Mutually exclusive with count.

providers

A map from the provider names the child expects to provider configurations of the caller.

depends_on

A list of resources or modules (never an arbitrary expression) that must be finished before anything inside the module is processed.

ignore_nested_deprecations

A boolean, default false. When true, deprecation warnings raised by the module call and by the modules nested in it are suppressed. Deprecated variables and outputs exist since Terraform 1.15.

Any other name

The value of the child module’s input variable of that name.

OpenTofu documents the same arguments and adds a lifecycle block with enabled (see Terraform and OpenTofu differences). Terraform’s module block reference lists no lifecycle block for modules.

After changing source or version, run terraform init again so the new code is installed.

Source types

The source argument is parsed by the same library family that Terraform uses to download providers' archives, which is why the address looks like a URL with prefixes. The next sections follow the order of the official reference. Whatever the type, Terraform copies the result into the working directory’s .terraform directory (the get command page names .terraform as the place modules are stored and recommends not committing it).

Local paths

A local path must start with ./ or ../ so that it cannot be confused with a registry address. It is the form used for the modules of the same repository.

module "network" {
  source = "./modules/network"
}

module "shared_tags" {
  source = "../shared/tags"
}

Local modules are used in place: the files are read from disk, so editing a local module is picked up by the next plan without re-running init (a new module block, or a changed source, still needs init). Local modules do not accept version. An absolute path (starting with / or a drive letter) is accepted, but the reference discourages it because it ties the configuration to one machine’s filesystem layout.

Local modules are the right default for code that belongs to a single repository: no publishing step, no versions, and one pull request changes caller and callee together. They stop being the right answer when several repositories need the same code, because then a change cannot be adopted gradually; that is when a versioned source (registry or Git tag) earns its keep.

Terraform Registry

The public registry (registry.terraform.io) addresses a module as <NAMESPACE>/<NAME>/<PROVIDER>, where the last segment is the main provider the module targets, not the cloud region or any other qualifier. Registry sources are the only ones that support the version argument.

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

  name = "bookshelf-dev"
  cidr = "10.20.0.0/16"

  azs             = ["eu-west-1a", "eu-west-1b"]
  private_subnets = ["10.20.1.0/24", "10.20.2.0/24"]
  public_subnets  = ["10.20.101.0/24", "10.20.102.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = true

  tags = {
    Application = "bookshelf"
    Environment = "dev"
  }
}

The module’s README documents name, cidr, azs, private_subnets, public_subnets, enable_nat_gateway and tags; single_nat_gateway is a further input documented in the same README, so check the inputs table of the version you pin. The README shows no version in its example, which is exactly the pitfall discussed below.

A private registry adds a hostname in front: <HOSTNAME>/<NAMESPACE>/<NAME>/<PROVIDER>. HCP Terraform’s private registry uses app.terraform.io, a Terraform Enterprise installation uses its own hostname, and both also accept localterraform.com as a way of asking the instance the run is executing on. Access tokens for private registries are stored through terraform login or in the CLI configuration (see CLI Essentials).

module "service" {
  source  = "app.terraform.io/example-corp/service/docker"
  version = "2.1.0"

  name  = "catalog-api"
  image = "registry.example.com/bookshelf/catalog-api:1.4.2"
}

The organization name and module are placeholders. Publishing to a registry is covered in Module Development and Publishing and the private registries of HCP Terraform in HCP Terraform and Enterprise.

GitHub, Bitbucket and generic Git

Addresses that start with github.com/ or git@github.com: are recognised as Git repositories without the git:: prefix; Bitbucket (bitbucket.org/) behaves the same way. For every other Git host use the git:: prefix followed by any URL that Git itself understands (HTTPS, ssh://, the scp-like git@host:path form, and others). Terraform runs a real git clone, so your local Git configuration, credential helpers and SSH keys all apply. HCP Terraform supports only SSH keys for authentication to Git sources.

Two query parameters select what is cloned:

  • ref takes anything git checkout accepts: a branch, a tag or a commit SHA-1. When it is omitted Terraform clones the branch that HEAD points to, which is the moving default branch and therefore unsuitable for production.

  • depth makes a shallow clone (git clone --depth). The reference recommends depth=1 for most cases because Terraform only needs the selected commit. When depth is used, ref is passed to --branch, so it must be a named branch or tag, not a raw commit ID.

module "tags_from_github" {
  source = "github.com/example-corp/terraform-docker-tags?ref=v1.2.0"
}

module "tags_ssh" {
  source = "git::ssh://git@git.example.com/platform/terraform-docker-tags.git?ref=v1.2.0&depth=1"
}

module "tags_by_commit" {
  source = "git::https://git.example.com/platform/terraform-docker-tags.git?ref=51d462976d84fdea54b47d80dcabbf680badcdb8"
}

The repositories are placeholders. A tag is mutable in Git (somebody can delete and recreate it), so teams that need a strong guarantee pin a full commit SHA and let a tool such as Renovate or Dependabot propose bumps. Unlike a registry module, a Git source has no version constraint: ref=v1.2.0 is an exact pin, and upgrading is an explicit edit of the address.

Subdirectories of a package

A repository or an archive is a package, and a package can contain several modules. A double slash separates the package address from a path inside it. Query parameters go after the subdirectory:

module "network" {
  source = "git::https://git.example.com/platform/terraform-modules.git//network?ref=v3.4.0"
}

module "from_registry_subdir" {
  source  = "hashicorp/consul/aws//modules/consul-cluster"
  version = "0.1.0"
}

Terraform downloads the entire package but reads the module from the subdirectory. A module in the package can call a sibling with a local path (../other), and that works after installation because both are inside the same downloaded copy. This is the usual layout of mono-repositories that publish many modules with one tag. The registry example is a placeholder for the syntax; consult a module’s own documentation for the submodules it really offers.

HTTP and HTTPS URLs

An http:// or https:// address is fetched with a GET request that carries a terraform-get=1 query parameter. The server answers either with an X-Terraform-Get response header or, for an HTML page, with a <meta name="terraform-get" content="..."> tag, and the value is another source address. URLs ending in .zip, .tar.bz2, .tbz2, .bz2, .tar.gz, .tgz, .gz, .tar.xz, .txz or .xz are treated as archives and downloaded directly. For an archive without a recognised extension add archive as a query parameter, for example ?archive=zip. Credentials come from a .netrc file in the home directory; the NETRC environment variable moves it.

module "archived_tags" {
  source = "https://artifacts.example.com/terraform/docker-tags-1.2.0.zip"
}

module "archived_without_extension" {
  source = "https://artifacts.example.com/terraform/download?id=docker-tags-1.2.0&archive=zip"
}

An archive URL has no version constraint either; the version is part of the file name, which is the reason artifact-server layouts almost always include it.

S3 and GCS buckets

Modules can be distributed as archives in object storage, which suits organizations that already have a private bucket and do not run a registry. Prefix the object URL with s3:: or gcs::.

module "tags_from_s3" {
  source = "s3::https://s3-eu-west-1.amazonaws.com/example-corp-terraform-modules/docker-tags-1.2.0.zip"
}

module "tags_from_gcs" {
  source = "gcs::https://www.googleapis.com/storage/v1/example-corp-terraform-modules/docker-tags-1.2.0.zip"
}

The bucket names are placeholders. S3 credentials are discovered in this order: the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables, the default profile of ~/.aws/credentials, then an EC2 instance profile. A bucket in us-east-1 must use the host s3.amazonaws.com. GCS credentials come from GOOGLE_OAUTH_ACCESS_TOKEN, GOOGLE_APPLICATION_CREDENTIALS (a service account key file), the default credentials of a Compute Engine instance, or gcloud auth application-default login. On a CI runner prefer short-lived federated credentials (OIDC) over long-lived keys; the same applies to the plain init that downloads these modules.

Mercurial

The hg:: prefix runs hg clone and selects a revision with #. Mercurial sources are rare today; they are mentioned so the prefix is not a surprise when reading old configurations.

OCI registries (OpenTofu)

OpenTofu 1.10 added the oci: scheme, which installs a module package from an OCI (container) registry. This is an OpenTofu-only source type: Terraform does not support it.

module "service" {
  source = "oci://registry.example.com/bookshelf/modules/service?tag=v1.0.0"
}

module "service_pinned" {
  source = "oci://registry.example.com/bookshelf/modules/service?digest=sha256:0123456789abcdef"
}

The domain of the address is the OCI registry and the rest names the repository in it. Without a parameter OpenTofu uses the tag latest; tag and digest are mutually exclusive. A digest refers to exactly one manifest, so it is the strongest pin available. The digest above is a placeholder and not a valid value. Authentication is covered by the OpenTofu pages about OCI registry credentials.

Choosing a source type

Source Good for Watch out for

Local path

Code that lives and changes with the caller, wrappers around a registry module.

No versioning; a module shared by many repositories cannot be copied.

Public registry

Well-known community modules, a version constraint, rendered documentation.

Trust and maintenance depend on the author (see the evaluation checklist below).

Private registry

Company-wide modules with SemVer and access control (HCP Terraform, Terraform Enterprise, TACOS).

Needs a token in CI.

Git with ref

Modules in a private repository without a registry.

ref is an exact pin and not a constraint; a missing ref follows the default branch.

HTTP, S3 or GCS archives

Environments that already have artifact storage.

No constraint solver; the version lives in the file name.

OCI (OpenTofu)

Environments that already operate an OCI registry for container images.

Not available in Terraform.

Versions and constraints

For registry modules, version takes the same constraint syntax as providers: =, !=, >, >=, <, <= and the pessimistic operator ~>, combined with commas. Module versions must follow semantic versioning. Terraform uses the newest installed version that satisfies the constraint and downloads the newest matching one if nothing is installed. If version is omitted the latest available version is used, which is why the reference strongly recommends always constraining it.

module "exact" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.0.1"
}

module "minor_range" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"
}

module "bounded" {
  source  = "terraform-aws-modules/vpc/aws"
  version = ">= 6.0, < 7.0"
}

Version constraints are described in more detail in Types and Expressions and, for providers, in Providers. Two properties differ from providers:

  • There is no lock file for modules. The dependency lock file (.terraform.lock.hcl) records providers only. A constraint such as ~> 6.0 therefore resolves again on every machine that runs init without the .terraform directory, and a new patch release can change what a fresh checkout installs. Exact versions (6.0.1) are the way to get reproducibility for modules, at the price of manual bumps.

  • terraform init installs only modules that are missing. Re-running it does not move modules already installed; terraform init -upgrade updates all modules to the latest source code that the constraints allow, and terraform get -update does the same for modules only.

A module’s own terraform block constrains its required Terraform and provider versions, and those are checked together with the root’s. Use a conservative range in the root (for example ~> 1.9) and let reusable modules declare only a lower bound, as described in Module Development and Publishing.

Variables and locals in source and version

Until Terraform 1.14 the arguments source and version had to be literal strings, so teams templated them with external tools or copy-pasted the same Git address into dozens of blocks. Terraform 1.15 (released 2026-04-29) allows input variables and locals in both attributes. Because modules are installed during terraform init, before any plan exists and before most variable values are known, there are strict rules:

  • An input variable referenced in source or version (directly, or through a local that uses it) must be declared with const = true. The variable block reference describes const as the setting that allows a variable to be used in early operations such as initialization.

  • A constant variable must hold a known value; it cannot depend on anything computed during a plan.

  • The expressions in source and version may reference only values known while the configuration is loaded. Anything that is only known after planning is an error.

  • Because the values are needed before the configuration is evaluated, commands that read modules accept variable values: terraform init, terraform get, terraform modules, terraform providers lock and others take -var and -var-file. The changelog for 1.15 records that most commands now accept variable values for this reason.

variable "module_repo" {
  type  = string
  const = true
}

variable "module_ref" {
  type    = string
  const   = true
  default = "v3.4.0"
}

locals {
  network_source = "git::https://git.example.com/${var.module_repo}.git//network?ref=${var.module_ref}"
}

module "network" {
  source = local.network_source
}

variable "vpc_module_version" {
  type    = string
  const   = true
  default = "~> 6.0"
}

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = var.vpc_module_version
}

Supply the values the usual ways: -var, -var-file, TF_VAR_ environment variables or .tfvars files.

export TF_VAR_module_repo="platform/terraform-modules"
terraform init
# or, equivalently
terraform init -var 'module_repo=platform/terraform-modules' -var 'module_ref=v3.5.0'

When is this feature worth using?

  • One place for a shared pin. A local holds the repository address and the ref, and a dozen module blocks share it.

  • Mirrors and air-gapped networks. The host in a Git or registry address becomes a variable that a CI job sets to an internal mirror.

  • Per-environment module versions. A .tfvars file per environment can promote v3.5.0 to staging before prod.

It also has costs. A source that depends on a variable is harder to grep, tools that parse HCL statically (Dependabot, Renovate, some scanners) cannot follow it, and a wrong value only fails at init. Prefer literal sources and versions unless the indirection removes real duplication.

In OpenTofu

OpenTofu was first. Version 1.8 allowed variables and locals in module sources and backend configurations ("with limitations"), and its module sources page says both source and version can use them, with the values resolving during tofu init and unable to reference state data or provider-defined functions. OpenTofu does not require const = true: static evaluation applies to ordinary variables. The OpenTofu 1.8.3 release notes added an option to mark variables used in module sources, versions and backends as sensitive, described as off by default; check the current OpenTofu documentation for the present default. This means a configuration that uses a plain variable in source works in OpenTofu and fails in Terraform until the variable gets const = true. Adding const is harmless in Terraform only; OpenTofu 1.13 documentation was not found to describe a const argument, so verify before sharing such code between the two tools.

Inputs and outputs

A module call is an assignment of values to the child’s input variables. The child declares its interface with variable and output blocks (details in Variables, Outputs and Locals), and the caller sees nothing else.

Passing inputs

Arguments of the module block whose names are not meta-arguments are inputs. The type, default, validation and nullable rules of the child’s variables apply to them. Unknown inputs (values only available after apply) are accepted; they appear as "known after apply" in the plan.

# modules/credentials/variables.tf
variable "name_prefix" {
  type        = string
  description = "Prefix for generated secret names."
}

variable "length" {
  type    = number
  default = 24

  validation {
    condition     = var.length >= 16
    error_message = "Generated credentials must be at least 16 characters long."
  }
}

# modules/credentials/main.tf
terraform {
  required_providers {
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

resource "random_password" "this" {
  length  = var.length
  special = false
}

# modules/credentials/outputs.tf
output "password" {
  value     = random_password.this.result
  sensitive = true
}

output "label" {
  value = "${var.name_prefix}-db"
}

The module is the local ./modules/credentials used earlier. random_password.result is a sensitive attribute, so the output must be marked sensitive = true or Terraform refuses to plan.

The child can never read the parent’s variables or locals: everything it needs must arrive as an argument. The reverse is also true; a parent that wants a local value from a child needs an output. This strictness is deliberate and is the reason a module can be tested and reused independently.

Reading outputs

Outputs of a child are available to the caller as module.<label>.<output>. With count the reference is module.<label>[0].<output> and with for_each it is module.<label>["key"].<output>; a splat (module.x[*].id) or a for expression collects them.

module "service" {
  source   = "./modules/service"
  for_each = toset(["catalog-api", "reviews-api", "web"])

  name = each.key
}

output "service_urls" {
  value = { for name, svc in module.service : name => svc.url }
}

Outputs also feed other modules, and that is the normal way of building composition: the output of network is the input of database and service. Passing identifiers rather than letting each module create or look up its own network is called dependency inversion in the module composition guidance, and it keeps modules small and easy to reuse. The caller can later replace a managed network with a data source lookup without touching the receiving module.

module "network" {
  source = "./modules/network"

  name = "bookshelf-dev"
}

module "database" {
  source = "./modules/database"

  network_name = module.network.name
}

Terraform infers the dependency from the reference, so database waits for network. No depends_on is needed.

Sensitive values

A sensitive output stays sensitive when the caller passes it on. Sensitive values still end up in the state file in clear text, so the state backend must be treated as secret storage; see State and Sensitive Data, Ephemeral Values and Write-Only Arguments for the ephemeral and write-only alternatives.

Deprecated inputs and outputs

Since Terraform 1.15 a module author can mark a variable or output as deprecated; a caller that uses it receives a warning that includes the author’s message. ignore_nested_deprecations = true on the module block hides these warnings for that call and everything nested below it. Use it as a temporary measure while a migration is scheduled, not as a permanent setting. Authors use the feature to warn about breaking changes ahead of a major version (see Refactoring: moved and removed).

Installing and managing modules

terraform init and terraform get

terraform init finds every module block and retrieves the code from the location in source. Running it again installs modules added since the last run but leaves installed ones as they are. Its module-related options:

Option Effect

-upgrade

Updates all modules (and providers) to the latest versions the constraints permit. Without it existing modules are not changed.

-get=false

Skips installing child modules. Use it only when the directory has already been initialized with its modules.

-from-module=SOURCE

In an empty directory, copies the module at SOURCE into it before the other steps, for starting from an example or checking out a configuration.

-var, -var-file

Supply const variables needed by source and version.

terraform get does only the module part of init: it downloads and updates the modules declared in the root module and stores them in .terraform of the working directory.

$ terraform get
$ terraform get -update
$ terraform get -var-file=modules.tfvars

-update checks already installed modules for updates. -var and -var-file supply the values of const variables used in source and version. In practice init is the command people run, because it installs providers and the backend as well and complains when something is missing; get is useful in tooling that needs only modules. OpenTofu’s tofu get also has -update, -var, -var-file and, additionally, -json and -json-into.

The .terraform directory is a cache. Do not commit it, and expect CI to run init on every build. To make CI faster you can cache the directory, but key the cache on the lock file and on a hash of the module sources and versions, or you can serve stale code.

terraform modules

terraform modules (Terraform 1.10 and later) prints every module declared in the configuration of the current directory as a tree. It is intended for auditing which modules and versions are consumed, and for policies on them. Each entry shows the module key, its source in brackets and, for registry modules, the version and constraint. Local modules show their path and no version.

$ terraform modules
.
├── "network" [./modules/network]
├── "database" [./modules/database]
└── "vpc" [terraform-aws-modules/vpc/aws] (6.0.1, ~> 6.0)

The output above is illustrative and abbreviated; the exact layout is described on the command page. With -json it produces a document with format_version (1.0 in Terraform 1.10) and a modules array whose objects have key, source and version (an empty string for local modules).

terraform modules -json | jq -r '.modules[] | [.key, .source, .version] | @tsv'

The command also takes -var and -var-file for const variables. It is a convenient step for a CI job that checks that every non-local module uses an approved source. The section CLI Essentials lists the other commands that inspect configuration. OpenTofu’s command reference has no tofu modules command; tofu get installs modules, and tofu show -json or the .terraform/modules/modules.json manifest lists them.

What happens to the state

State records every resource with its module path, for example module.network.docker_network.this. Renaming a module label or moving a resource into a module changes the address, and Terraform would plan to destroy and recreate it. Use a moved block to tell Terraform that the object is the same; see Refactoring: moved and removed. Removing a module block plans to destroy everything in it, and a removed block can instead drop it from state while leaving the infrastructure running.

Meta-arguments on modules

Four meta-arguments work on module blocks, with the same semantics as on resources, plus lifecycle in OpenTofu.

count and for_each

count and for_each instantiate the whole module several times. A module created with for_each is addressed with the key (module.service["catalog-api"]), and one created with count with an index (module.service[1]), so the guidance about stable keys applies unchanged: removing an element from the middle of a count list shifts the indexes of the elements after it, whereas for_each keys stay put. The mechanics and the unknown-values error are covered in Meta-Arguments: count, for_each and depends_on.

locals {
  services = {
    catalog-api = { image = "bookshelf/catalog-api:1.4.2", port = 8080 }
    reviews-api = { image = "bookshelf/reviews-api:1.1.0", port = 8081 }
    web         = { image = "bookshelf/web:3.0.0", port = 8000 }
  }
}

module "service" {
  source   = "./modules/service"
  for_each = local.services

  name  = each.key
  image = each.value.image
  port  = each.value.port
}

for_each is the right choice when instances differ; count suits near-identical copies and, with a value of 0 or 1, the pattern of switching a module on or off (count = var.enable_cache ? 1 : 0). A disabled module disappears from the output namespace, so consumers use one(module.cache[*].url) or a conditional. OpenTofu can switch off a module with lifecycle { enabled = ... } instead, which keeps the reference stable.

The keys of for_each must be known at plan time. A for_each over values that are only known after apply fails; derive keys from variables and locals, and pass the unknown parts as values.

depends_on

depends_on on a module makes all resources and data sources inside the module wait for the listed objects. Its list can contain only references to resources and modules of the calling module, not expressions.

module "web" {
  source = "./modules/service"

  name = "web"

  depends_on = [module.database]
}

The reference warns that depends_on should be a last resort: it yields more conservative plans that replace more than necessary, and shows more values as "known after apply", which the page says is especially likely with modules. Prefer passing real values (module.database.host as an input), which creates a precise dependency. Use depends_on only for hidden dependencies such as an IAM policy that must exist before a service starts. Cycles between modules can occur when one uses an output of the other and also depends on it; see The Dependency Graph and the Plan.

providers

By default a child module inherits the default (unaliased) provider configurations of its parent. Aliased configurations, and any case where inheritance is not what you want, need the providers argument:

provider "aws" {
  region = "eu-west-1"
}

provider "aws" {
  alias  = "replica"
  region = "eu-central-1"
}

module "covers_primary" {
  source = "./modules/bucket"

  name = "bookshelf-covers"
}

module "covers_replica" {
  source = "./modules/bucket"

  name = "bookshelf-covers-replica"

  providers = {
    aws = aws.replica
  }
}

Only configurations are inherited, not the source address or version constraint, so each module declares its own required_providers. A module that needs more than one configuration of the same provider (for example a cross-region replication module) lists the aliases it expects in configuration_aliases and its resources select them with provider = aws.src or provider = aws.dst; the caller then passes both through providers. The details, including the wording of a module that declares two aliases, are in Providers.

Associations are static: the providers map cannot be built with a for_each, so instances that need different providers need separate module blocks. A reusable module must not contain its own provider blocks. The reference states that a provider configuration must outlive the resources it manages, and removing a module that contains both destroys both at once. Terraform also refuses count, for_each and depends_on on a module that contains a nested provider configuration.

Finding and evaluating third-party modules

The public registry hosts thousands of modules, and an equal number of GitHub repositories claim to be one. A module is code that runs with the credentials of your pipeline and creates resources in your accounts, so it deserves the scrutiny of any dependency.

Where to look

  • The Terraform Registry. The search box matches module name, provider and description, and the filters narrow the results; the Partner filter limits results to modules from partners that HashiCorp has reviewed for stability and compatibility. Each page shows the README, inputs, outputs, resources, dependencies and the versions published.

  • The OpenTofu Registry for the same purposes when you run OpenTofu; it indexes the same public ecosystem.

  • Family collections. The terraform-aws-modules collection for AWS, Azure Verified Modules for Azure, and terraform-google-modules plus Cloud Foundation Fabric for Google Cloud are widely used families with consistent conventions. They are covered in the provider pages: Terraform on AWS, Terraform on Azure and Terraform on Google Cloud.

  • Your organization’s private registry, if there is one.

Registry naming and structure

The registry only lists modules in GitHub repositories named terraform-<PROVIDER>-<NAME> (public registry), tagged with SemVer tags (the v prefix is optional), and following the standard module structure. These rules explain what you see on a module page and why a repository that is not published has no version history. Publishing is described in Module Development and Publishing.

Evaluation checklist

Question What to look at

Is it maintained?

Date of the last release, open issues and pull requests, whether breaking changes follow SemVer, how quickly security fixes ship.

Who owns it?

A vendor or a recognizable community organization, several maintainers, the Partner filter. A single-author module is not wrong, but it is a single point of failure.

Does it fit?

Inputs and outputs cover your use case without forcing you to fork. Count the required inputs and how many resources it creates by default; a "kitchen sink" module with 200 inputs is hard to review.

What does it create?

The resources list on the registry page, and a read of main.tf. Look for public access defaults, wide IAM policies, unencrypted storage.

What does it require?

required_version and provider ranges. A module that needs a provider major your configuration does not use blocks an upgrade.

What does it depend on?

Nested modules and provisioner or external data sources. Both widen what runs on your machine.

Is it tested and documented?

An examples/ directory, automated tests, a changelog, a clear upgrade guide for major versions.

Can you pin it?

A published tag or exact version you can reference, ideally a signed commit or a release you can mirror.

Safe consumption

  • Pin exact versions in production. Use version = "6.0.1" (or ~> 6.0 in a root module with a lock-style review) and read the changelog before bumping. There is no module lock file, so the exact pin plus review is the lock.

  • Run terraform plan on every bump and read the diff. A minor release of a module can rename a resource inside it, which shows as a destroy and create. Module authors can protect users with moved blocks; if they did not, add one in your configuration.

  • Wrap when you need policy. A thin local module that calls the registry module with the organization’s defaults (naming, tags, encryption) gives one place to apply policy and a single edit point when you later replace the upstream module. Do not wrap a module just to rename its variables.

  • Scan the code. Policy and security scanners work on module code as well as on your own; see Code Quality and Security Scanning.

  • Mirror critical modules. For air-gapped or supply-chain-sensitive environments, copy the module to an internal Git repository or registry and point source there, for example with a const variable for the host.

  • Do not use ref=main. A moving branch changes the infrastructure between two applies without any change in your repository.

A module is not always the right tool. If a registry module wraps a single resource and adds nothing but renamed arguments, the resource block is clearer. If you need only one of its twenty resources, copy the pattern instead of the dependency.

Bookshelf scenario

Bookshelf has the same three services in each environment. The root module of one environment calls three local modules, one per concern, and a registry module for the network when the cloud variant is used. The local variant below runs with the Docker provider and shows only the module wiring; the bodies of the modules are developed in Module Development and Publishing.

bookshelf/
├── envs/
│   └── dev/
│       ├── main.tf
│       ├── providers.tf
│       ├── variables.tf
│       └── terraform.tfvars
└── modules/
    ├── network/
    ├── database/
    └── service/
# envs/dev/main.tf
terraform {
  required_version = ">= 1.9.0, < 2.0.0"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.9"
    }
  }
}

provider "docker" {}

module "network" {
  source = "../../modules/network"

  name = "bookshelf-${var.environment}"
}

module "database_credentials" {
  source = "../../modules/credentials"

  name_prefix = "bookshelf-${var.environment}"
  length      = 32
}

module "database" {
  source = "../../modules/database"

  network_name = module.network.name
  password     = module.database_credentials.password
}

module "service" {
  source   = "../../modules/service"
  for_each = var.services

  name         = each.key
  image        = each.value.image
  port         = each.value.port
  network_name = module.network.name
  db_host      = module.database.host
}

output "service_urls" {
  value = { for name, svc in module.service : name => svc.url }
}

The network child can be as small as a single docker_network resource named after its name input:

# modules/network/main.tf
terraform {
  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = ">= 4.0"
    }
  }
}

variable "name" {
  type = string
}

resource "docker_network" "this" {
  name = var.name
}

output "name" {
  value = docker_network.this.name
}

On a cloud environment only the network call changes: the local module is replaced by the registry module of the first example (terraform-aws-modules/vpc/aws, version = "~> 6.0") and the database and service modules receive subnet IDs instead of a network name. The inputs of those modules are the contract; the implementation can be swapped, which is the point of the interface. The complete scenario, with a covers bucket module, secrets created in the root module and a real-cloud variant, is built in Bookshelf End to End; DNS records and IAM roles would follow the same module pattern.

Running the environment installs the local modules as part of init and shows them in the plan with their module path:

$ terraform init
Initializing the backend...
Initializing modules...
Initializing provider plugins...
...
$ terraform plan
...
  # module.network.docker_network.this will be created
  # module.service["catalog-api"].docker_container.this will be created
  # module.service["reviews-api"].docker_container.this will be created
  # module.service["web"].docker_container.this will be created
Plan: 6 to add, 0 to change, 0 to destroy.

The output above is illustrative and abbreviated, not captured from a run. Notice how the three services appear with for_each keys, so removing reviews-api from var.services destroys exactly that instance and nothing else.

Terraform and OpenTofu differences

Area Terraform 1.17 OpenTofu 1.13

Variables and locals in source and version

Since 1.15, requires const = true on the variables.

Since 1.8, via static (early) evaluation of ordinary variables and locals; values must be known during tofu init.

OCI module sources

Not available.

oci:// with tag or digest, since 1.10.

Bitbucket sources

bitbucket.org/ addresses behave like other Git sources and accept ref and depth.

Documented as working only for public repositories, because OpenTofu queries the Bitbucket API to detect the version control system.

HTTP source detection

Sends terraform-get=1, reads X-Terraform-Get header or terraform-get meta tag.

Sends tofu-get=1, reads the X-Terraform-Get header or a tofu-get meta tag.

Default registry

registry.terraform.io

registry.opentofu.org; full hostnames pin one or the other.

Module lifecycle

Not documented on the module block reference.

lifecycle { enabled = bool } on module blocks; false excludes the module as if the call did not exist.

Removing a module without destroying it

removed block (see the refactoring page).

removed block with from = module.<name> and lifecycle { destroy = false }.

get command options

-update, -var, -var-file, -no-color.

The same, plus -json and -json-into.

Module file extension

.tf and .tf.json.

Also .tofu files, loaded in preference to the same-named .tf.

A configuration that stays within the common subset (local and registry sources, literal versions, count, for_each, providers, depends_on) works unchanged in both tools. Registry-hosted community modules are available from both registries; for a module that exists on only one, give the full source address with a hostname. The wider comparison and migration guidance are in OpenTofu.

Pitfalls

  • No version on a registry module. The latest release is installed, so a fresh init on a new machine can install a major version with breaking changes. Always set version.

  • Constraints are not locked. A range such as ~> 6.0 is resolved again whenever modules are installed from scratch; use exact versions or review module bumps like dependencies.

  • ref omitted or pointing at a branch. The clone follows the moving branch.

  • depth with a commit SHA. A shallow clone passes ref as a branch name, so a raw commit ID fails.

  • Variables in source without const = true. In Terraform 1.15 and later this is an error at init; in OpenTofu it works, which hides the difference when you switch tools.

  • Changing source or version without init. Terraform reports that the module is not installed or its source changed. Run init (and -upgrade to move to a newer matching version).

  • Reaching into a module. References like module.x.aws_s3_bucket.b do not exist. Export an output instead.

  • Provider blocks in shared modules. They block count, for_each and depends_on and make removal fail. Pass providers from the root.

  • Moving resources into or out of a module. Without moved blocks the plan is a destroy and recreate.

  • depends_on on large modules. It delays every data source inside and makes plans noisy. Prefer value references.

  • Sensitive values in outputs. Mark them sensitive, and remember that they remain in state.

  • Absolute local paths. The code is copied from a machine-specific location; use relative paths.

  • Unreviewed third-party code. A module runs with your credentials; read what it creates before you apply.

What changed recently

  • Terraform 1.15 (2026-04-29): variables and locals are allowed in module source and version (const = true variables only), most commands accept -var and -var-file for that reason, the deprecated argument on variables and outputs exists, and the module block has ignore_nested_deprecations.

  • Terraform 1.17 (2026-10-21): variables and locals in provider requirements follow the same early-evaluation rules as module sources (see Providers).

  • Terraform 1.10: the terraform modules command (text tree and -json).

  • OpenTofu 1.8: variables and locals in module sources and backends; OpenTofu 1.10: the oci: module source and OCI registries as provider mirrors.

  • Older tutorials and books. Material written for Terraform 0.11 and earlier shows provider configurations declared inside modules. The version argument and registry sources date from 0.11, the providers map and configuration_aliases replace nested provider blocks, and count, for_each and depends_on on modules arrived in Terraform 0.13. A book that says modules cannot declare dependencies or be repeated describes the older releases. Treat claims that source and version must be literal strings as outdated for Terraform 1.15 and later.

The overall list of migration notes is collected in What Changed and Migration.