linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
home/terraform/kb/Modules/tf-module-composition

kb/modules ── Modules ── intermediate

Module composition: a module of modules and passing providers

A module can call other modules inside its own main.tf, using the same `module` block. The address in state becomes `module.A.module.B.<type>.<name>`. Providers are inherited by default, but with several aliases (multi-region, multi-account) you pass them explicitly through `providers = { aws = aws.eu }`. `for_each` over a module works with TF 0.13+.

view as markdownaka: terraform-module-composition, terraform-nested-modules

A module inside a module

Inside a child module you can declare another module block, exactly the way you do in the root:

hcl
# modules/app/main.tf
module "data_bucket" {
  source = "../s3-bucket"   # relative to the file where the block is written
  name   = "${var.app_name}-data"
}
module "logs_bucket" {
  source = "../s3-bucket"
  name   = "${var.app_name}-logs"
}

And the call from the root:

hcl
# main.tf
module "billing" {
  source   = "./modules/app"
  app_name = "billing"
}

In state you get:

module.billing.module.data_bucket.aws_s3_bucket.this
module.billing.module.logs_bucket.aws_s3_bucket.this

The address mirrors the hierarchy. You can have three levels, four, as many as you like, but do not go past two without a strong reason. Each level is one more layer of indirection when you debug.

Relative path in source

source = "../s3-bucket" is a path relative to the module's .tf file (here, app), not to the root. That helps when you move folders around: the whole ./modules/ tree stays internally consistent no matter where you put it.

Sharing data between modules

One module returns an output, another takes it as an input:

hcl
module "vpc" {
  source = "./modules/vpc"
  cidr   = "10.0.0.0/16"
}
module "alb" {
  source     = "./modules/alb"
  vpc_id     = module.vpc.id
  subnet_ids = module.vpc.public_subnet_ids
}

Terraform builds the dependency for you: module.alb is created after module.vpc. No hand-written depends_on, the interpolation makes it implicit. See tf-depends-on.

If you need an explicit depends_on on a module (module X must wait for module Y, but Y's output is not used in X), it is available with TF 0.13+:

hcl
module "alb" {
  source     = "./modules/alb"
  vpc_id     = module.vpc.id
  depends_on = [module.iam_roles]
}

This is a rare case. If you find yourself writing depends_on on a module, ask whether you could pass an output instead. Usually you can.

Passing providers

By default a module inherits the provider from its parent:

hcl
# root
provider "aws" {
  region = "us-east-1"
}
module "logs" {
  source = "./modules/s3-bucket"
  # the module uses provider "aws" automatically, there is only one.
}

Things get harder with aliased providers. Take a multi-region resource:

hcl
provider "aws" {
  region = "us-east-1"
  alias  = "us"
}
provider "aws" {
  region = "eu-west-1"
  alias  = "eu"
}
module "logs_us" {
  source = "./modules/s3-bucket"
  providers = {
    aws = aws.us
  }
  name = "linuxlab-logs-us"
}
module "logs_eu" {
  source = "./modules/s3-bucket"
  providers = {
    aws = aws.eu
  }
  name = "linuxlab-logs-eu"
}

If a module expects several aliases, it has to declare them itself:

hcl
# modules/multi-region-bucket/versions.tf
terraform {
  required_providers {
    aws = {
      source                = "hashicorp/aws"
      configuration_aliases = [aws.primary, aws.replica]
    }
  }
}

And in the module's HCL:

hcl
resource "aws_s3_bucket" "primary" {
  provider = aws.primary
  bucket   = var.name
}
resource "aws_s3_bucket" "replica" {
  provider = aws.replica
  bucket   = "${var.name}-replica"
}

The call:

hcl
module "geo" {
  source = "./modules/multi-region-bucket"
  providers = {
    aws.primary = aws.us
    aws.replica = aws.eu
  }
  name = "geo-data"
}

This is the hardest case. In simple modules you do not need configuration_aliases, provider inheritance is enough.

for_each and count over a module

With TF 0.13+ for_each and count work over a module block:

hcl
variable "buckets" {
  type = map(object({
    versioning = bool
  }))
  default = {
    logs = { versioning = false }
    data = { versioning = true }
  }
}
module "bucket" {
  source   = "./modules/s3-bucket"
  for_each = var.buckets
  name               = "linuxlab-${each.key}"
  versioning_enabled = each.value.versioning
}

You get module.bucket["logs"] and module.bucket["data"] in state. To reach them from the root:

hcl
output "all_arns" {
  value = { for k, m in module.bucket : k => m.arn }
}

This is a basic intermediate technique: one module declaration plus a map gives you N instances, each with its own parameters. See tf-count-for-each.

When for_each, when count

Same rule as for resources: a map for named things (stable keys when you remove an entry), count for indexed things (when order and quantity matter more than names). For modules, for_each is almost always the better choice.

Passing variables as a whole

When you have many modules with shared parameters, lift them into locals:

hcl
locals {
  common_tags = {
    Owner   = "platform-team"
    Project = "billing"
    Env     = var.env
  }
}
module "app" {
  source = "./modules/app"
  tags   = local.common_tags
  # ...
}
module "billing" {
  source = "./modules/billing"
  tags   = local.common_tags
  # ...
}

Do not "pass each tag separately" through five variables. One tags, one map(string), and the modules extend it with merge(var.tags, {Module = "..."}).

Pitfalls

  • Do not write providers inside a child module. You can declare provider "aws" { ... } inside a child module, but you should not. It has been deprecated since TF 0.13. Providers are configured in the root and passed into the child through providers = {}.

  • for_each on a module plus count inside the module is allowed but puzzling. State turns into module.X["key"].aws_s3_bucket.this[0]. That is hard to debug. Try to keep the hierarchy flat.

  • depends_on on a module is not perfect before TF 1.4. In older versions, dependencies declared with depends_on on a module fired incompletely. Terraform could start destroying a resource before destroying the module it "depended on". TF 1.4+ fixes this, but if you live on 1.0 through 1.3, do not rely on it.

  • You cannot reference module.X as a whole. output "all_arns" { value = module.bucket } does not work. Only specific outputs do: module.bucket.arn, module.bucket.bucket. If you want "everything", assemble it by hand into an object.

  • Deep nesting breaks state mv. Moving a resource from module.A.module.B.module.C.aws_s3_bucket.this somewhere else means a long path that is easy to get wrong. Keep the tree flat (root plus one level of modules) rather than a chain of three or four.

  • Passing providers breaks when you remove an alias. Drop provider "aws" { alias = "eu" } from the root, and every module with providers = { aws = aws.eu } fails at plan. Remove the module block first, then the alias, not the other way around.

§ команды

bash
terraform state list

Shows every resource from every module level. The addresses spell out the hierarchy.

bash
terraform plan -target=module.vpc

Plan only the vpc module (and its dependencies). Useful for debugging dependencies.

bash
terraform graph | dot -Tpng > /tmp/g.png

Graph of modules and resources. Shows why apply builds things in this order.

bash
terraform providers

Shows which providers (and their aliases) each module uses. Helps when debugging how they are passed.

§ см. также

  • tf-module-basicsModule: a reusable piece of infrastructureA module is any directory that contains `.tf` files and can be referenced via a `module` block. The root module is the directory where you run terraform. Child modules are the ones being called. The module contract: input variables (what it accepts), output values (what it exposes). Everything else is an implementation detail.
  • tf-module-inputs-outputsThe module contract: input variables and outputsFrom the outside, a module is visible only through its input variables (what it accepts) and its output values (what it returns). Everything else is implementation detail. A good module hides its resources behind this contract so you can rewrite the body without touching the calls in the root module.
  • tf-count-for-eachcount and for_each: many resources from one blockcount creates N identical resources by index 0..N-1. for_each creates resources keyed from a set or map. Rule of thumb: count for identical copies, for_each when each one has its own settings. When in doubt, use for_each.
  • tf-provider-blockThe provider block: who Terraform will callThe provider block configures the plugin: which AWS region to talk to, which endpoints to use, which credentials to take. One block per provider is usually enough.
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies