Supporting both OpenTofu and Terraform in Your Modules

Kieran LoweKieran Lowe
8 min read

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.

💡
OpenTofu has official migration guides at: Migrating to OpenTofu 1.7.x from Terraform | OpenTofu for different versions of Terraform. There isn’t an official one for OpenTofu/Terraform v1.9.0 yet, as at the time of writing it is only available in alpha, but I’m just following what is documented at: Migrating to OpenTofu from Terraform 1.8.x | OpenTofu.

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"
I love the -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:

  1. Created a network module

  2. Used .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.

0
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