Supporting both OpenTofu and Terraform in Your Modules


Introduction
Let’s start by saying this isn’t going to be a post trying to convince you which tool to use; you should assess both and make your own decision. But what I want to talk about today is one of the ways in which module practitioners could support both tools in their modules. Initially, Terraform and OpenTofu were in feature parity, but are now starting to diverge with their own unique features, such as:
Native state/plan encryption in OpenTofu v1.7.0
Terraform Ephemeral Resources in v1.10.0
.tofu
overrides in OpenTofu v1.8.0
The latter is what I’m actually going to be talking about today. You can read a little more at: Files and Directories | OpenTofu but to summarise: a file with the same name that ends with .tofu
will take precedence over one ending with .tf
. So if you have two files named: vpc.tofu
and vpc.tf
, OpenTofu will only load vpc.tofu
and ignore vpc.tf
.
This is a very easy way for module authors to incorporate both into their modules, such as using features exclusively available within each tool. This means module authors don’t have to create a completely separate repository/module, resulting in a lot of code duplication and overhead. You can argue however supporting both is additional overhead, and that you might want to keep your modules as agnostic as possible. But I feel with the way they are already starting to diverge, this will only get harder as it goes on.
Anyway, let’s test .tofu
overrides!
Defining the Configuration
Module
Let’s start with the configuration of the module. It will be a basic setup:
modules/network/provider.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0, < 6.0.0"
}
}
}
modules/network/vpc.tf
resource "aws_vpc" "vpc" {
cidr_block = var.vpc_cidr
tags = merge(local.additional_tags, {
Name = "example-vpc"
})
}
modules/network/subnets.tf
resource "aws_subnet" "subnet" {
for_each = var.subnets
vpc_id = aws_vpc.vpc.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = merge(local.additional_tags, {
Name = each.key
})
}
modules/network/tags.tf
locals {
additional_tags = {
DeployedWith = "Terraform"
}
}
modules/network/tags.tofu
locals {
additional_tags = {
DeployedWith = "OpenTofu"
}
}
modules/network/variables.tf
variable "vpc_cidr" {
description = "The CIDR block for the VPC"
type = string
}
variable "subnets" {
description = "Subnet configuration"
type = map(object({
cidr = string
az = string
}))
}
You’ll notice above our module contains a tags.tf
and tags.tofu
. Terraform will ignore tags.tofu
and OpenTofu will only look at tags.tofu
, ignoring tags.tf
. As both of these files have the same local
name of additional_tags
, we can easily standardise the rest of the module to call local.additional_tags
for the tags
argument of the aws_vpc
and aws_subnet
resources, without having to create separate .tofu
files for those.
Root Configuration
Now that we have our module, we can write the code to call it and define the input variables.
main.tf
module "network" {
source = "./modules/network"
vpc_cidr = "10.0.0.0/16"
subnets = {
private-1 = {
cidr = "10.0.0.0/24"
az = "eu-west-2a"
}
private-2 = {
cidr = "10.0.1.0/24"
az = "eu-west-2b"
}
private-3 = {
cidr = "10.0.2.0/24"
az = "eu-west-2c"
}
}
}
provider.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0.0, < 6.0.0"
}
}
}
provider "aws" {
region = "eu-west-2"
}
Deployment
Now we have our configuration, let’s deploy it with Terraform! We’ll start by running a terraform init
followed by a terraform plan -out=tf.plan
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
+ create
Terraform will perform the following actions:
# module.network.aws_subnet.subnet["private-1"] will be created
+ resource "aws_subnet" "subnet" {
+ arn = (known after apply)
+ assign_ipv6_address_on_creation = false
+ availability_zone = "eu-west-2a"
+ availability_zone_id = (known after apply)
+ cidr_block = "10.0.0.0/24"
+ enable_dns64 = false
+ enable_resource_name_dns_a_record_on_launch = false
+ enable_resource_name_dns_aaaa_record_on_launch = false
+ id = (known after apply)
+ ipv6_cidr_block_association_id = (known after apply)
+ ipv6_native = false
+ map_public_ip_on_launch = false
+ owner_id = (known after apply)
+ private_dns_hostname_type_on_launch = (known after apply)
+ tags = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-1"
}
+ tags_all = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-1"
}
+ vpc_id = (known after apply)
}
# module.network.aws_subnet.subnet["private-2"] will be created
+ resource "aws_subnet" "subnet" {
+ arn = (known after apply)
+ assign_ipv6_address_on_creation = false
+ availability_zone = "eu-west-2b"
+ availability_zone_id = (known after apply)
+ cidr_block = "10.0.1.0/24"
+ enable_dns64 = false
+ enable_resource_name_dns_a_record_on_launch = false
+ enable_resource_name_dns_aaaa_record_on_launch = false
+ id = (known after apply)
+ ipv6_cidr_block_association_id = (known after apply)
+ ipv6_native = false
+ map_public_ip_on_launch = false
+ owner_id = (known after apply)
+ private_dns_hostname_type_on_launch = (known after apply)
+ tags = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-2"
}
+ tags_all = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-2"
}
+ vpc_id = (known after apply)
}
# module.network.aws_subnet.subnet["private-3"] will be created
+ resource "aws_subnet" "subnet" {
+ arn = (known after apply)
+ assign_ipv6_address_on_creation = false
+ availability_zone = "eu-west-2c"
+ availability_zone_id = (known after apply)
+ cidr_block = "10.0.2.0/24"
+ enable_dns64 = false
+ enable_resource_name_dns_a_record_on_launch = false
+ enable_resource_name_dns_aaaa_record_on_launch = false
+ id = (known after apply)
+ ipv6_cidr_block_association_id = (known after apply)
+ ipv6_native = false
+ map_public_ip_on_launch = false
+ owner_id = (known after apply)
+ private_dns_hostname_type_on_launch = (known after apply)
+ tags = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-3"
}
+ tags_all = {
+ "DeployedWith" = "Terraform"
+ "Name" = "private-3"
}
+ vpc_id = (known after apply)
}
# module.network.aws_vpc.vpc will be created
+ resource "aws_vpc" "vpc" {
+ arn = (known after apply)
+ cidr_block = "10.0.0.0/16"
+ default_network_acl_id = (known after apply)
+ default_route_table_id = (known after apply)
+ default_security_group_id = (known after apply)
+ dhcp_options_id = (known after apply)
+ enable_dns_hostnames = (known after apply)
+ enable_dns_support = true
+ enable_network_address_usage_metrics = (known after apply)
+ id = (known after apply)
+ instance_tenancy = "default"
+ ipv6_association_id = (known after apply)
+ ipv6_cidr_block = (known after apply)
+ ipv6_cidr_block_network_border_group = (known after apply)
+ main_route_table_id = (known after apply)
+ owner_id = (known after apply)
+ tags = {
+ "DeployedWith" = "Terraform"
+ "Name" = "example-vpc"
}
+ tags_all = {
+ "DeployedWith" = "Terraform"
+ "Name" = "example-vpc"
}
}
Plan: 4 to add, 0 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Saved the plan to: tf.plan
To perform exactly these actions, run the following command to apply:
terraform apply "tf.plan
Now if we look at the plan, it wants to apply the DeployedWith = Terraform
tag to the AWS VPC and subnet resources. This is what we are expecting when deploying with Terraform. So let’s apply the plan:
module.network.aws_vpc.vpc: Creating...
module.network.aws_vpc.vpc: Creation complete after 2s [id=vpc-04844a7243dbeea38]
module.network.aws_subnet.subnet["private-1"]: Creating...
module.network.aws_subnet.subnet["private-2"]: Creating...
module.network.aws_subnet.subnet["private-3"]: Creating...
module.network.aws_subnet.subnet["private-3"]: Creation complete after 0s [id=subnet-08e3160cd14be1370]
module.network.aws_subnet.subnet["private-2"]: Creation complete after 0s [id=subnet-0168d332f5e39ad2a]
module.network.aws_subnet.subnet["private-1"]: Creation complete after 0s [id=subnet-0800b6f97bd1fff19]
If we check on the AWS Console, we can see the correct tag key-value combination!
We could now start a fresh configuration to deploy with OpenTofu, but we’re actually going to migrate the current configuration from Terraform v1.10.0 to OpenTofu v1.8.6.
We’ll start with a tofu init
followed by a tofu plan -concise -out=t.plan
:
OpenTofu used the selected providers to generate the following execution plan. Resource actions are indicated with the
following symbols:
~ update in-place
OpenTofu will perform the following actions:
# module.network.aws_subnet.subnet["private-1"] will be updated in-place
~ resource "aws_subnet" "subnet" {
id = "subnet-0800b6f97bd1fff19"
~ tags = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
"Name" = "private-1"
}
~ tags_all = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
# (1 unchanged element hidden)
}
# (15 unchanged attributes hidden)
}
# module.network.aws_subnet.subnet["private-2"] will be updated in-place
~ resource "aws_subnet" "subnet" {
id = "subnet-0168d332f5e39ad2a"
~ tags = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
"Name" = "private-2"
}
~ tags_all = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
# (1 unchanged element hidden)
}
# (15 unchanged attributes hidden)
}
# module.network.aws_subnet.subnet["private-3"] will be updated in-place
~ resource "aws_subnet" "subnet" {
id = "subnet-08e3160cd14be1370"
~ tags = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
"Name" = "private-3"
}
~ tags_all = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
# (1 unchanged element hidden)
}
# (15 unchanged attributes hidden)
}
# module.network.aws_vpc.vpc will be updated in-place
~ resource "aws_vpc" "vpc" {
id = "vpc-04844a7243dbeea38"
~ tags = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
"Name" = "example-vpc"
}
~ tags_all = {
~ "DeployedWith" = "Terraform" -> "OpenTofu"
# (1 unchanged element hidden)
}
# (14 unchanged attributes hidden)
}
Plan: 0 to add, 4 to change, 0 to destroy.
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Saved the plan to: t.plan
To perform exactly these actions, run the following command to apply:
tofu apply "t.plan"
-concise
flag of OpenTofu! While it’s not a major difference in this example, if you manage a bunch of resources it can really shorten the plan output by hiding “refreshing” log lines.Here we can see it wants to change the value of DeployedWith
from Terraform
to OpenTofu
- this is expected! Because OpenTofu is only looking at tags.tofu
which is:
locals {
additional_tags = {
DeployedWith = "OpenTofu"
}
}
Now lets apply it:
module.network.aws_vpc.vpc: Modifying... [id=vpc-04844a7243dbeea38]
module.network.aws_vpc.vpc: Modifications complete after 1s [id=vpc-04844a7243dbeea38]
module.network.aws_subnet.subnet["private-1"]: Modifying... [id=subnet-0800b6f97bd1fff19]
module.network.aws_subnet.subnet["private-2"]: Modifying... [id=subnet-0168d332f5e39ad2a]
module.network.aws_subnet.subnet["private-3"]: Modifying... [id=subnet-08e3160cd14be1370]
module.network.aws_subnet.subnet["private-2"]: Modifications complete after 0s [id=subnet-0168d332f5e39ad2a]
module.network.aws_subnet.subnet["private-1"]: Modifications complete after 0s [id=subnet-0800b6f97bd1fff19]
module.network.aws_subnet.subnet["private-3"]: Modifications complete after 0s [id=subnet-08e3160cd14be1370]
Again, let’s check the AWS Console:
Conclusion
Let’s summarise what we’ve done:
Created a
network
moduleUsed
.tofu
overrides to differentiate deployments using Terraform and OpenTofu
I appreciate this is a very basic example of using .tofu
overrides, but it’s a nice and easy way for module authors/practitioners to support both.
Subscribe to my newsletter
Read articles from Kieran Lowe directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Kieran Lowe
Kieran Lowe
Cloud Engineer and DevOps