A module inside a module
Inside a child module you can declare another module block, exactly the
way you do in the root:
# 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:
# 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:
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+:
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:
# 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:
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:
# 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:
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:
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:
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:
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:
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 declareprovider "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 throughproviders = {}. -
for_eachon a module pluscountinside the module is allowed but puzzling. State turns intomodule.X["key"].aws_s3_bucket.this[0]. That is hard to debug. Try to keep the hierarchy flat. -
depends_onon a module is not perfect before TF 1.4. In older versions, dependencies declared withdepends_onon 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.Xas 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.thissomewhere 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
providersbreaks when you remove an alias. Dropprovider "aws" { alias = "eu" }from the root, and every module withproviders = { aws = aws.eu }fails at plan. Remove themoduleblock first, then the alias, not the other way around.