DEV Community

Cover image for Terraform testing with Open Policy Agent and Conftest: Secure infrastructure through Terraform testing
Florian Lenz
Florian Lenz

Posted on

Terraform testing with Open Policy Agent and Conftest: Secure infrastructure through Terraform testing

Terraform has established itself as the leading tool for infrastructure as code: modules describe resources, plans show the planned changes, and an apply implements the configuration in the cloud. In practice, however, Terraform configurations are often only checked briefly, perhaps using terraform validate, and the rest of the code relies on peer reviews and good intentions. This is not enough, especially in regulated industries or security-critical projects. Errors such as incorrectly set defaults, publicly accessible resources, or missing encryption can lead to data breaches and high costs. In this article, you will learn about an approach that allows you to consistently test Terraform configurations—without creating real resources. The basis is the JSON output of terraform plan, which is checked against defined rules using Open Policy Agent (OPA) and Conftest.

Why is testing Terraform configurations important?

Terraform defines productive infrastructure. A single configuration error can make a database accessible worldwide, disable encryption, or sabotage cost control due to missing tags. According to recent cloud security studies, 80% of companies were affected by cloud security incidents last year, and Gartner predicts that by 2025, 99% of cloud security failures will be customer-related. This is particularly true for misconfigurations. More than 32% of reported incidents stem directly from misconfigurations. These figures illustrate that infrastructure code must be subjected to the same rigorous testing as application code.

Latent misconfigurations that lie dormant in existing resources are particularly dangerous. For example, an AzureStorage account is publicly accessible by default via the public_network_access_enabled attribute. If this property is not explicitly disabled, the storage account is openly accessible. The same applies to AWS S3 buckets, security groups with open ports, or missing encryption. In large teams with many modules and different environments (Dev, QA, Prod), manually checking plans quickly becomes confusing. That's why we need an automated, repeatable testing approach that takes effect before execution.

Limitations of classic terraform test approaches

Since version 1.6, Terraform has included its own testing mechanism (terraform test). On paper, this sounds appealing—but in practice, weaknesses quickly become apparent. Testing with terraform test is an additional step in the workflow: in addition to plan and apply, another command must be integrated into the CI/CD pipeline, maintained, and understood. Many tests also require resources to be actually created in a test environment. This means additional cloud costs, authorization effort, and potentially long runtimes. In strictly regulated areas, this is often not approvable.
Although efforts are being made to run tests based on the plan, these functions are still in their infancy and offer only limited support for complex compliance rules. The added value compared to a direct evaluation of the JSON plan remains low.

Another disadvantage is that terraform test is closely tied to the Terraform world. The rules are difficult to transfer to other systems, resulting in an isolated solution. For organizations that use Kubernetes manifests, Helm charts, or other IaC formats in addition to Terraform, this leads to fragmented governance approaches. There is no uniform policy level. This is where OPA and Conftest come in.

Policy-as-Code and Open Policy Agent

Policy-as-Code (PaC) is the principle of defining policies as versionable code and enforcing them automatically. Instead of relying on manual checks or written guidelines, rules are formulated in a declarative language. The Open Policy Agent (OPA) is a universal policy engine that evaluates these rules in the Rego language. OPA is used for Kubernetes admission controllers, API authorization, and cloud governance, among other things. Crucially, OPA is data-driven: it accepts any JSON and returns a decision.

The core of the approach is simple: instead of just checking the planned changes, we validate the complete future state of the infrastructure before resources are created. Terraform provides everything we need for this. With terraform plan -out=tfplan, we generate a binary plan and then convert it into a machine-readable JSON file with terraform show -json. This JSON contains both the current state and the planned changes and the resulting end result – including modules, defaults, and dependencies.

Conftest is a CLI wrapper around OPA that is specifically designed for checking structured files such as JSON, YAML, or HCL. A policy in Rego format is stored in a policy/ folder. When running conftest test against the JSON plan file, Conftest evaluates this policy and reports violations. This allows policies for Terraform, Kubernetes, and other formats to be bundled into a single tool.

Pipeline integration

In a typical CI/CD pipeline, the process looks like this:

  1. Create plan: terraform plan -out=tfplan.binary generates a binary plan file.
  2. Convert plan: terraform show -json tfplan.binary > tfplan.json converts the plan to JSON.
  3. Evaluate policies: conftest test tfplan.json executes the Rego rules against the JSON. Each deny rule results in an error in the pipeline job.

This approach has several advantages. First, there are no cloud costs because no resources are built. Second, the test can be run early in the pipeline—even before merge requests are accepted. Third, the same policies can also be used for other technologies.

Example: Azure Storage Account without public access

A concrete example illustrates the method. Suppose that every Azure Storage account must disable public network access. In the Terraform module, we see a resource of type azurerm_storage_account:

resource "azurerm_storage_account" "this" {
  name                     = var.storage_account_name
  resource_group_name      = var.resource_group_name
  location                 = var.location
  account_tier             = "Standard"
  account_replication_type = "LRS"

  public_network_access_enabled = false
}
Enter fullscreen mode Exit fullscreen mode

The following Rego code defines a policy that ensures that public_network_access_enabled is set to false for every scheduled azurerm_storage_accountResource change:

package main

deny[msg] {
  some rc in input.planned_values
  rc.type == "azurerm_storage_account"
  rc.change.actions[_] == "create"
  val := object.get(rc.change.after, "public_network_access_enabled", null)
  val != false
  msg := sprintf("%s: public_network_access_enabled muss false sein, gefunden %v", [rc.address, val])
}
Enter fullscreen mode Exit fullscreen mode

When you run conftest test tfplan.json, the plan is checked against this rule. If public_network_access_enabled is not defined or is true, the test fails and outputs a clear error message. This prevents an insecure storage account from being accepted in the plan at all.

Extension to the entire future state

The above example only checks for resource changes. However, in many projects, it is important to validate the entire future state. Existing resources that remain unchanged in the current plan may still be non-compliant. The JSON object planned_values describes the final state after apply. A policy that recursively runs through all modules and checks every azurerm_storage_account in the future state looks like this:

package terraform.azure.storage

# All storage accounts in planned state
storage_accounts contains sa if {
  # walk runs recursively through the entire object
  some path, node
  walk(input.planned_values.root_module, [path, node])

  # We are only interested in resource objects of type Storage Account
  node.type == “azurerm_storage_account”

  sa := node
}

# Violation if public_network_access_enabled is not exactly false
deny contains msg if {
  some i
  sa := storage_accounts[i]

  # Read value (null if not set)
  val := object.get(sa.values, “public_network_access_enabled”, null)

  # Anything other than false is prohibited (true or null / not set)
  val != false

  msg := sprintf(
    “Azure Storage Account ‘%s’ has invalid public_network_access_enabled value: %v”,
    [sa.name, val],
  )
}
Enter fullscreen mode Exit fullscreen mode

This rule runs through all modules, collects every storage resource, and checks the properties in the future state. This allows latent misconfigurations to be detected, even if the resource in question is not changed in the current plan.

Integration into the CI/CD pipeline

A solid pipeline should include the following steps:

  • Code analysis & formatting: Use tools such as terraform fmt and tflint.
  • Security scanners: Tools such as tfsec, Checkov, or Regula can perform static analyses based on known best practices.
  • Policy checks with OPA/Conftest: Run the plan through OPA/Conftest before applying and block merge requests in case of violations. Be sure not to version the JSON file in the repository, but to generate it temporarily in the pipeline job.
  • Drift detection: Use terraform plan regularly, even in production environments, to detect deviations between the code and the actual state. A comparison with OPA can indicate whether resources have been changed retrospectively.

With this type of multi-stage pipeline design, you can combine code quality, security scanning, and policy enforcement. When choosing tools, compatibility and maintainability should be taken into account. OPA and Conftest can be integrated into GitHub Actions, GitLab CI, Jenkins, Azure DevOps, or Terraform Enterprise and generate machine-readable reports.

Conclusion

Infrastructure as code offers many advantages, but also carries risks. Traditional testing in Terraform is either too closely tied to the ecosystem or requires the creation of real resources. By combining terraform plan, Open Policy Agent, and Conftest, infrastructure plans can be reviewed early on, cost-neutrally, and comprehensively. This approach validates not only changes, but also the entire future state – a decisive advantage for audit and compliance requirements. At the same time, misconfigurations continue to be cited as one of the main causes of security incidents. That's why testing Terraform with OPA should be integrated into every modern DevOps pipeline. This turns “infrastructure as code” into “infrastructure with guarantees” – secure, compliant, and traceable.

FAQ

What is the difference between terraform validate and OPA/Conftest tests?
terraform validate only checks the syntax and basic structure of the code. OPA/Conftest, on the other hand, enable semantic checks: they detect whether certain attributes are set correctly, tags are present, or security requirements are met. This allows compliance rules to be enforced automatically before resources are created.

Do I have to apply the plan for OPA tests?
No. The big advantage of OPA/Conftest is that you can export the Terraform plan as JSON and check it without cloud access. No resources are created, so there are no costs. The plan represents the future state against which the policies are evaluated.

How do I integrate OPA into my pipeline?
You can integrate OPA/Conftest into any CI/CD tools. In GitHub Actions, all you need is an additional job that runs terraform plan, terraform show -json, and conftest test. GitLab CI and Azure DevOps have similar mechanisms. It is important that the job aborts the build if conftest reports a violation.

Can the same policies also be used for Kubernetes or other tools?
Yes. OPA is not limited to Terraform. The policies check any JSON or YAML structures, such as Helm charts, Kubernetes manifests, or CloudFormation templates. This allows you to create a uniform governance layer across different platforms.

What happens if my Terraform modules change?
Since the test is based on the JSON plan, it doesn't matter how your modules are structured internally. As long as the resources appear in the plan, they will be checked. For new modules, you may need to define additional rules to cover new resource types.

How do I deal with complex rules?
Rego is a powerful language, but it also allows for complexity. For complex rules, it is recommended to build policies in a modular way, use helper functions, and write your own tests for the policies. The OPA Playground and community rules (e.g., for Kubernetes) offer helpful examples.

Top comments (0)