Terraform version 1.15.0-alpha20260304 is released and with it comes another feature which I have been keeping an eye out for. I will be honest that I skipped past the original PR for const being added to variable blocks a few times before realizing what feature it would come under ;).

Modules have become the standard to create the golden pathways to provision infrastruture in most organizations in the way they desire ; according to their naming conventions, security standards and so on. Some stand up their own modules. Some use the public ones directly, while a few might fork the public community modules into their own git repositories. While all of this helps bring standardization, managing a versioned entity comes with the questions around upgrades and a good way to handle those environments/lifecycles.

  • Are the updates in the upstream module safe for me to fork and use ?
  • Are the new changes already available for any wrapper modules I may be using ?
  • Can I test this in a single environment without having to update my prod or staging as I have some existing releases planned which I don’t want to disrupt ?

Some of these have lead teams to create copies of module invocation across different folders in a single repo for provisioning the same set of infrastructure with some small changes due to module upgrades in a lifecycle compared to the other. I am not saying all of this is addressed in its entirety with the post here, but it does give you a good way to redfine your processes around module sources.With PR #38217 merged, you can now use variables in module source and version attributes in your current configurations. Lets dive into it.

Note: This post covers features from Terraform 1.15.0-alpha20260304. As this is an alpha release, syntax and behavior may change before the final release. Do not use this in production yet.

Pre-1.15-alpha

I have terraform aliased to tf if you see that being used across the code blocks. Confirm the version of Terraform binary installed.

tf --version
Terraform v1.14.6
on darwin_arm64

A typical module usage looks something like below. Picking the easiest commmunity module which has not many inputs for this post and some scenarios here.

# main.tf 
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "6.6.0"
}

To address some of the questions I posted above, the natural inclination would be to look at some parameterization in the module source. Can we make these source and version be variables ?

module "vpc" {
  source  = var.module_source
  version = var.module_version
}

# variables.tf
variable "module_source" {
  type    = string
  default = "terraform-aws-modules/vpc/aws"
}

variable "module_version" {
  type    = string
  default = "6.6.0"
}
tf init
Initializing the backend...
Initializing modules...
│ Error: Variables not allowed
│   on main.tf line 2, in module "vpc":
│    2:   source  = var.module_source
│ Variables may not be used here.
│ Error: Unsuitable value type
│   on main.tf line 2, in module "vpc":
│    2:   source  = var.module_source
│ Unsuitable value: value must be known
│ Error: Variables not allowed
│   on main.tf line 3, in module "vpc":
│    3:   version = var.module_version
│ Variables may not be used here.

Post-alpha

From the PR documentation, this is what I am gathering:

  • You can use variables inside the module blocks for source and version (where it is supported). Version doesn’t come into play in case of inline modules or git sourced modules.
  • You express the variables which need to be used with an identifier called const=true. This ties back to the requirement of the value needing to be known while you perform an initialization which downloads the necessary modules for your configuration.

Let’s change some of the fields in our previous example to this update. First off, update our terraform version to use the latest alpha.

tf --version
Terraform v1.15.0-alpha20260304
on darwin_arm64

Scenario 1

Lets upgrade the Terrform version and leave the variables how they were in the previous example.

module "vpc" {
  source  = var.module_source
  version = var.module_version
}

# variables.tf
variable "module_source" {
  type    = string
  default = "terraform-aws-modules/vpc/aws"
}

variable "module_version" {
  type    = string
  default = "6.6.0"
}

Run terraform init.

tf init
Initializing modules...
╷
│ Error: Invalid module version
│
│   on main.tf line 3, in module "vpc":
│    3:   version = var.module_version
│
│ The module version contains a reference that is unknown during init.
╵

The error message has changed from not being able to use variables to a reference unknown during init. There comes the const attribute. Lets try with the updated configuration. The language server might still give the Unexpected attribute: An attribute named "const" is not expected here (Terraform) message. Lets ignore that for the time being.

# main.tf
module "vpc" {
  source  = var.module_source
  version = var.module_version
}

# variables.tf
variable "module_source" {
  type    = string
  default = "terraform-aws-modules/vpc/aws"
  const   = true
}

variable "module_version" {
  type    = string
  default = "6.6.0"
  const   = true
}

Run terraform init.

tf init
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 6.6.0 for vpc...
- vpc in .terraform/modules/vpc
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching ">= 6.28.0"...
- Installing hashicorp/aws v6.34.0...
- Installed hashicorp/aws v6.34.0 (signed by HashiCorp)

Initializing the backend...

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!

Woah ! It worked. It used the source and version references passed into the module block from the variable. Now, I still need to get my head around const used with variables. They just don’t go together in my head. If you do a terraform init with the TF_LOG set to trace or debug, you could see the path to resolution is a little different compared to the previous version. You can ignore this block if you are only concerned about the usage side of it.

### Terraform 1.14.6 (Static)
[TRACE] ModuleInstaller: installing child modules
[TRACE] ModuleInstaller: vpc is not yet installed
[TRACE] ModuleInstaller: vpc is a registry module
(downloads module)

### Terraform 1.15.0-alpha (Dynamic)
[TRACE] building graph for terraform dependencies
[TRACE] ModuleTransformer: Added module.vpc as *terraform.nodeInstallModule
[DEBUG] ReferenceTransformer: "module.vpc" references: [var.module_source var.module_version]
[DEBUG] Starting graph walk: walkInit
[TRACE] NodeRootVariable: evaluating var.module_source
[TRACE] NodeRootVariable: evaluating var.module_version
[TRACE] vertex "module.vpc": starting visit
[TRACE] vertex "module.vpc": expanding dynamic subgraph
[TRACE] Completed graph transform:
module.vpc - *terraform.nodeInstallModule
  var.module_source - *terraform.NodeRootVariable
  var.module_version - *terraform.NodeRootVariable
root - terraform.graphNodeRoot
  module.vpc - *terraform.nodeInstallModule
var.module_source - *terraform.NodeRootVariable
var.module_version - *terraform.NodeRootVariable

 [TRACE] PrepareFinalInputVariableValue: preparing var.module_source                                                                                  
 [TRACE] PrepareFinalInputVariableValue: var.module_source has a default value
 [TRACE] PrepareFinalInputVariableValue: var.module_source has no defined value                                                                       
 [TRACE] vertex "var.module_source": visit complete
 [TRACE] NodeRootVariable: evaluating var.module_version                                                                                            
 [TRACE] PrepareFinalInputVariableValue: preparing var.module_version
 [TRACE] PrepareFinalInputVariableValue: var.module_version has a default value                                                                       
 [TRACE] PrepareFinalInputVariableValue: var.module_version has no defined value

Let’s look at some other scenarios in the mean time.

Scenario 2: Environment-Based Versioning

What if I could set some module versions based on some other conditional , like the environment I am deploying to ? The thing to keep in mind is that the variables referenced to provide that information for the module block need to be able to gather that value for the init step.

# variables.tf
variable "environment" {
  type        = string
  # const       = true  <- commented
  description = "Deployment environment"
}

# data.tf
locals {
  module_versions = {
    dev     = "5.2.0"
    staging = "5.1.0"
    prod    = "5.0.0"
  }
  module_version = local.module_versions[var.environment]
}

# main.tf
module "versioned" {
  source  = "terraform-aws-modules/vpc/aws"
  version = local.module_version
}

With const attibute commented out, we see the error below. In my first run of this, I forgot about the const attribute while I was copying the examples over. But it ascertains the need for the const attribute on any variables used for module sources.

Initializing modules...
╷
│ Error: Invalid module version
│
│   on main.tf line 3, in module "versioned":
│    3:   version = local.module_version
│
│ The module version contains a reference that is unknown during init.

With no default values, as expected we get terraform complaining about it.

Initializing modules...
╷
│ Error: No value for required variable
│
│   on main.tf line 2:
│    2: variable "environment" {
│
│ The root module input variable "environment" is not set, and has no default value. Use a -var or -var-file command line argument to provide a value for this variable.

Let’s use some runtime args to help us here.

environment=dev:

tf init --var=environment=dev
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.2.0 for versioned...
- versioned in .terraform/modules/versioned
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching ">= 5.0.0"...
- Installing hashicorp/aws v6.34.0...
- Installed hashicorp/aws v6.34.0 (signed by HashiCorp)

Initializing the backend...
Terraform has been successfully initialized!

environment=prod:

tf init --var=environment=prod
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.0.0 for versioned...
- versioned in .terraform/modules/versioned
Initializing provider plugins found in the configuration...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.34.0

Initializing the backend...
Terraform has been successfully initialized!

VPC module version swapped between 5.2.0 and 5.0.0 based on the environment we are targetting.

Would I use this ? Maybe, when I am testing a new version in the lower environments ;especially patch or minor versions which doesn’t have a breaking change on the inputs. What else does this unlock ? I am going a little crazy with this , but why not !!

Scenario 3 : version reference in git sourced modules ?

One of the primary ways organizations share Terraform modules is via some git repository. Whats the difference you ask ? The git sourced modules do not have a version attribute, but rather expects it as part of the source itself following the ref= . With the option to construct the source dynamically, this changes things.

Lets use the tfvars to pass in some input to determine what version of the ref you want.

# main.tf
module "glue" {
  source = "git::https://github.com/quixoticmonk/terraform-aws-glue.git?ref=${local.selected_version}"
}

#variables.tf
variable "version" {
  type        = string
  const       = true
  description = "Version to use: stable, latest, or main"
  validation {
    condition     = contains(["stable", "latest", "main"], var.version)
    error_message = "Version must be stable, latest, or main."
  }
}

# data.tf
locals {
  git_versions = {
    stable = "v0.0.2"
    latest = "v0.0.3"
    main   = "main"
  }
  selected_version = local.git_versions[var.version]
}

Run terraform init

tfi --var-file=dev.tfvars
Initializing modules...
Downloading git::https://github.com/quixoticmonk/terraform-aws-glue.git?ref=main for glue...
- glue in .terraform/modules/glue
│ Error: Invalid variable name
│   on main.tf line 7, in variable "version":
│    7: variable "version" {
│ The variable name "version" is reserved due to its special meaning inside module blocks.

Hmmm, thats not what I expected. Seems like version is a reserved keyword. What about source ? Lets take a step back and define just the variables and see how init behaves.

# variables.tf
variable "source" {
  type        = string
  description = "Source path for the module"
}

variable "version" {
  type = string
  description = "version to be used"
}

Run terraform init :

tfi
│ Error: Terraform encountered problems during initialisation, including problems
│ with the configuration, described below.
│ The Terraform configuration must be valid before initialization so that
│ Terraform can determine which modules and providers need to be installed.
│ Error: Invalid variable name
│   on variables.tf line 2, in variable "source":
│    2: variable "source" {
│ The variable name "source" is reserved due to its special meaning inside module blocks.
│ Error: Invalid variable name
│   on variables.tf line 7, in variable "version":
│    7: variable "version" {
│ The variable name "version" is reserved due to its special meaning inside module blocks.

Let’s rename those variables and test what we wanted to start with :

# main.tf
module "glue" {
  source = "git::https://github.com/quixoticmonk/terraform-aws-glue.git?ref=${local.selected_version}"
}

#variables.tf
variable "module_version" {
  type        = string
  const       = true
  description = "Version to use: stable, latest, or main"

  validation {
    condition     = contains(["stable", "latest", "main"], var.module_version)
    error_message = "Version must be stable, latest, or main."
  }
}

# data.tf
locals {
  git_versions = {
    stable = "v0.0.2"
    latest = "v0.0.3"
    main   = "main"
  }
  selected_version = local.git_versions[var.module_version]
}

# dev.tfvars 
module_version = "main"

# prod.tfvars 
module_version = "stable"

Init with dev tfvars :

tfi --var-file=dev.tfvars
Initializing modules...
Downloading git::https://github.com/quixoticmonk/terraform-aws-glue.git?ref=main for glue...  # main branch
- glue in .terraform/modules/glue
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching ">= 5.0.0"...
...

Initializing the backend...

Init with prod tfvars :

tfi --var-file=prod.tfvars
Initializing modules...
Downloading git::https://github.com/quixoticmonk/terraform-aws-glue.git?ref=v0.0.2 for glue... # v0.0.2 release tag
- glue in .terraform/modules/glue
Initializing provider plugins found in the configuration...
....

Initializing the backend...

dev used the main branch reference while prod used v0.0.2 based on the map.

So the painpoint around git sourced modules can be resolved with the locally constructed source path. But it did bring up two constraints on common variable names which organizations might have.

source and version cannot be used as variables going forward as they have special meaning with the module blocks. That seems like a fair exchange for the benefits with the dynamic source we are getting.

Since we can change the source, why not define some variables to dictate if you should be able to use to the publicly available registry compared to the private ones. The example below is a little contrived, but you get the idea. I have seen customers who want to test their forks of public modules from their private repos in a lower environment before merging it in. Sometimes the blast radius of changes are not so visible in the changelogs or a small example which most modules keep. Running a plan against something which is currently using that module exhaustingly would surface any possible issues.

# variables.tf
variable "use_private_registry" {
  type        = bool
  const       = true
  description = "Whether to use private registry"
}

locals {
  registry_source = var.use_private_registry ? 
    "app.terraform.io/myorg/vpc/aws" : 
    "terraform-aws-modules/vpc/aws"
    
  module_version = var.use_private_registry ? "5.0.0" : "5.1.0"
}

# main.tf
module "vpc" {
  source  = local.registry_source
  version = local.module_version
}

Scenario 4: What about swapping a git sourced module with a registry module ?

Configuration:

variable "use_local" {
  type  = bool
  const = true
}

locals {
  source = var.use_local ? "./modules/local" : "terraform-aws-modules/vpc/aws"
}

module "example" {
  source  = local.source
  version = var.use_local ? null : "5.0.0"
}

Run terraform init :


tfi --var=use_local=true
Initializing modules...
╷
│ Error: Invalid registry module source address
│
│   on main.tf line 3, in module "example":
│    3:   source  = local.source
│
│ Failed to parse module registry address: can't use local directory "./modules/local" as a module registry address.
│
│ Terraform assumed that you intended a module registry source address because you also set the argument "version", which applies only to registry modules.
╵

This seems to be a NO-GO as Terraform assumes you want to use the registry source as an address when you set the argument version even though it will be null due to the value of use_local variable.

Even though version = var.use_local ? null : "5.0.0" should conditionally set the version, Terraform’s module type detection seems to happen at the structure level, not at the evaluation level. The presence of the version attribute in the HCL block triggers registry module handling from what I can understand. In internal/configs/module_call.go, the module block structure stores both source and version as HCL expressions:

type ModuleCall struct {
    Name string
    SourceExpr hcl.Expression  // Expression, not evaluated value
    VersionExpr hcl.Expression // Expression, not evaluated value
    // ...
}

And I think Terraform only checks if the attribute is present in the HCL block and expects it to be a registry module.

Scenario 5 - pass in versions or sources for nested modules

Nested modules are a matter of contention to start with. How many layers of abstraction would you create with modules? I get lost after a second layer. I am not entirely sure if this is a good idea, but in the spirit of experimenting here goes…

Consider a three-layer structure:

root (main.tf)
  └─> parent module (modules/parent)
       └─> child module (modules/child)
            └─> public registry module (terraform-aws-modules/vpc/aws)

Root module (main.tf):

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

  vpc_name           = "example-vpc"
  vpc_cidr           = "10.0.0.0/16"
  azs                = ["us-west-2a", "us-west-2b", "us-west-2c"]
  vpc_module_version = "~> 5.0"
}

Parent module (modules/parent/main.tf):

variable "vpc_module_version" {
  type        = string
  description = "Version constraint for VPC module"
  default     = "~> 5.0"
}

module "child" {
  source = "../child"

  vpc_name           = var.vpc_name
  vpc_cidr           = var.vpc_cidr
  azs                = var.azs
  vpc_module_version = var.vpc_module_version
}

Child module (modules/child/main.tf):

variable "vpc_module_version" {
  type        = string
  description = "Version constraint for VPC module"
  const       = true  # Required for dynamic version
}

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

  name = var.vpc_name
  cidr = var.vpc_cidr
  azs  = var.azs
  # ... other VPC configuration
}

Running terraform init:

tfi
Initializing modules...
- parent in modules/parent
- parent.child in modules/child
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.21.0 for parent.child.vpc... <- downloaded 5.21.0
- parent.child.vpc in .terraform/modules/parent.child.vpc
Initializing provider plugins found in the configuration...
- Finding hashicorp/aws versions matching ">= 5.79.0"...
- Installing hashicorp/aws v6.34.0...
- Installed hashicorp/aws v6.34.0 (signed by HashiCorp)

Initializing the backend...
..
Terraform has been successfully initialized!

Switching the input to use "~> 4.0" , you can see it downloads 4.x.x version.

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

  vpc_name           = "example-vpc"
  vpc_cidr           = "10.0.0.0/16"
  azs                = ["us-west-2a", "us-west-2b", "us-west-2c"]
  vpc_module_version = "~> 4.0"
}

On init:

tfi
Initializing modules...
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 4.0.2 for parent.child.vpc...
- parent.child.vpc in .terraform/modules/parent.child.vpc
Initializing provider plugins found in the configuration...
- Reusing previous version of hashicorp/aws from the dependency lock file
- Using previously-installed hashicorp/aws v6.34.0

Initializing the backend...

The thing to keep in mind here is that the variables which are passed for source/version should be a constant module variable. Again, the constant module variable term is a little hard to digest ;) The version constraint flows from root → parent → child → registry module. Although I stay away from multiple layers of abstration, this could solve a problem where your private module (which abstracts a child module) doesn’t need to be updated to use a newer version of the child module. Ideally you should be allowing the minor and patch versions to streamthrough anyway.

Things to keep in mind

So we went through a few scenarios of how this could be used or could come handy.

  1. The inputs version and source joins count as a reserved keyword.
  2. Any input which helps support this logical dynamic source allocation needs to have const=true attribute on it.
  3. The type of module sources ( registry vs git/local) do get inferred earlier in the lifecycle and so swapping them using this feature is not yet possible.

Conclusion

This definitely is in the list of items I am looking forward to use ( where possible) in some customer environments where module authors could use some more flexibility in terms of module versioning and testing. And I can see how this could come in handy if I have a policy which checks for allowed module sources and I don’t have to parse the HCL block to verify this.

References