Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

terranix-codegen generates Terranix NixOS modules from Terraform provider schemas.

What it does

Terranix lets you write Terraform configurations in Nix instead of HCL:

{
  resource.aws_instance.web = {
    ami = "ami-123456";
    instance_type = "t2.micro";
  };
}

This works, but every attribute is essentially untyped – Terranix accepts any attrset and passes it through to the Terraform JSON. You get no feedback from the NixOS module system about typos, wrong types, or missing required fields.

terranix-codegen fixes this by reading the Terraform provider schema (which defines every resource, every attribute, and every type) and generating NixOS module options declarations that match the exact structure Terranix already expects. You import the generated modules alongside your config and you get type checking, tab completion, and documentation for free – without changing how you write your Terranix code.

Usage

# Generate modules directly from provider specs (uses OpenTofu by default)
terranix-codegen generate -p aws -o ./providers
terranix-codegen generate -p hashicorp/aws:5.0.0 -p google -o ./providers

# Or from an existing schema JSON
tofu providers schema -json | terranix-codegen generate -o ./providers

# Inspect a provider schema
terranix-codegen show -p aws

# Dump schema as JSON
terranix-codegen schema -p aws --pretty

Pass -t terraform to use HashiCorp Terraform instead of OpenTofu.

Then import the generated modules in your Terranix config:

{
  imports = [ ./providers/registry.terraform.io/hashicorp/aws ];

  resource.aws_instance.web = {
    ami = "ami-123456";
    instance_type = "t2.micro";
  };
}

The generated modules only declare options – they don’t change the attrset structure or rename anything. Your existing Terranix configs work as before, but now with type checking.

Output structure

providers/
├── default.nix
└── registry.terraform.io/
    └── hashicorp/
        └── aws/
            ├── default.nix
            ├── provider.nix
            ├── resources/
            │   ├── default.nix
            │   ├── instance.nix
            │   └── ...
            └── data-sources/
                ├── default.nix
                ├── ami.nix
                └── ...

Each default.nix imports its siblings. You can import a whole provider or individual resource files.

Type Mapping

The type mapper converts Terraform’s go-cty types to NixOS module types. This is the core of the code generation pipeline – everything else is wiring.

Implementation: lib/TerranixCodegen/TypeMapper.hs

Mapping table

Primitives

TerraformNix
stringtypes.str
numbertypes.number
booltypes.bool
dynamictypes.anything

Collections

TerraformNixNotes
list(T)types.listOf (mapType T)
set(T)types.listOf (mapType T)Nix has no set type; mapped to list
map(T)types.attrsOf (mapType T)

Structural types

TerraformNix
object({...})types.submodule { options = {...}; }
tuple([...])types.tupleOf [...]

All mappings are recursive – a list(object({name = string})) produces types.listOf (types.submodule { options = { name = mkOption { type = types.str; }; }; }).

Objects

Terraform objects have typed fields, some of which may be optional:

CtyObject
  (Map.fromList [("host", CtyString), ("port", CtyNumber)])
  (Set.fromList ["port"])  -- optional fields

Generates:

types.submodule {
  options = {
    host = mkOption { type = types.str; };
    port = mkOption { type = types.nullOr types.number; };
  };
}

Optional object fields are wrapped in types.nullOr.

Tuples

Terraform tuples are fixed-length lists with per-position types. Nix has no built-in tuple type, so we provide a custom types.tupleOf that validates both length and per-element types.

CtyTuple [CtyString, CtyNumber, CtyBool]

Generates:

types.tupleOf [types.str types.number types.bool]

tupleOf is implemented as a proper mkOptionType with merge support, functor composition, and position-aware error messages.

Optional wrapping

The type mapper has two entry points:

  • mapCtyTypeToNix – returns the bare type
  • mapCtyTypeToNixWithOptional – wraps in types.nullOr when the attribute is optional or computed

This wrapping is applied at the attribute level by the option builder, not within nested type structures.

Attribute semantics

How Terraform’s required/optional/computed flags affect the generated option:

FlagsType wrappingDefaultreadOnly
requiredbare typenone (user must provide)no
optionaltypes.nullOr Tnullno
computed onlytypes.nullOr Tnullyes
optional + computedtypes.nullOr Tnullno

Block nesting modes

Nested blocks in Terraform schemas have a nesting mode that determines how they appear in configuration:

ModeNix typeDefault
singletypes.submodule { ... }null
grouptypes.submodule { ... }none
listtypes.listOf (types.submodule { ... })[]
settypes.listOf (types.submodule { ... })[]
maptypes.attrsOf (types.submodule { ... }){}

set maps to listOf for the same reason as collection sets – Nix doesn’t distinguish ordered from unordered at the type level.

Transformation Examples

Each example shows a Terraform provider schema fragment and the NixOS module that terranix-codegen generates from it.

Simple attributes

Schema:

{
  "version": 0,
  "block": {
    "attributes": {
      "name": {
        "type": "string",
        "description": "The name of the resource",
        "required": true
      },
      "enabled": {
        "type": "bool",
        "description": "Whether the resource is enabled",
        "optional": true
      },
      "count": {
        "type": "number",
        "description": "Number of instances",
        "optional": true
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_simple = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        name = mkOption {
          type = types.str;
          description = "The name of the resource";
        };
        enabled = mkOption {
          type = types.nullOr types.bool;
          default = null;
          description = "Whether the resource is enabled";
        };
        count = mkOption {
          type = types.nullOr types.number;
          default = null;
          description = "Number of instances";
        };
      };
    });
    default = {};
    description = "Instances of example_simple";
  };
}

Usage in Terranix:

{
  resource.example_simple.my_instance = {
    name = "production";
    enabled = true;
    count = 3;
  };
}

The outer types.attrsOf is what makes the instance name (my_instance) work as a key.

Collections

Schema:

{
  "version": 0,
  "block": {
    "attributes": {
      "availability_zones": {
        "type": ["list", "string"],
        "required": true
      },
      "tags": {
        "type": ["map", "string"],
        "optional": true
      },
      "security_group_ids": {
        "type": ["set", "string"],
        "optional": true
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_collections = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        availability_zones = mkOption {
          type = types.listOf types.str;
        };
        tags = mkOption {
          type = types.nullOr (types.attrsOf types.str);
          default = null;
        };
        security_group_ids = mkOption {
          type = types.nullOr (types.listOf types.str);
          default = null;
        };
      };
    });
    default = {};
    description = "Instances of example_collections";
  };
}

Note that set(string) maps to types.listOf types.str – Nix doesn’t have a set type.

Objects

Schema with a sensitive object attribute:

{
  "version": 0,
  "block": {
    "attributes": {
      "connection_info": {
        "type": ["object", {
          "host": "string",
          "port": "number",
          "username": "string",
          "password": "string"
        }],
        "description": "Database connection information",
        "required": true,
        "sensitive": true
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_object = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        connection_info = mkOption {
          type = types.submodule {
            options = {
              host = mkOption { type = types.str; };
              port = mkOption { type = types.number; };
              username = mkOption { type = types.str; };
              password = mkOption { type = types.str; };
            };
          };
          description = ''
            Database connection information

            NOTE: This attribute contains sensitive data.
          '';
        };
      };
    });
    default = {};
    description = "Instances of example_object";
  };
}

The sensitive flag becomes a note in the description.

Nested blocks (single)

Schema with a single-nesting block:

{
  "version": 0,
  "block": {
    "attributes": {
      "name": { "type": "string", "required": true }
    },
    "block_types": {
      "network_config": {
        "nesting_mode": "single",
        "block": {
          "attributes": {
            "subnet_id": { "type": "string", "required": true },
            "private_ip": { "type": "string", "optional": true, "computed": true }
          }
        }
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_nested_single = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        name = mkOption { type = types.str; };
        network_config = mkOption {
          type = types.submodule {
            options = {
              subnet_id = mkOption { type = types.str; };
              private_ip = mkOption {
                type = types.nullOr types.str;
                default = null;
              };
            };
          };
          default = null;
        };
      };
    });
    default = {};
    description = "Instances of example_nested_single";
  };
}

nesting_mode: "single" produces a bare types.submodule with default = null.

Nested blocks (list)

Schema with a list-nesting block:

{
  "version": 0,
  "block": {
    "attributes": {
      "name": { "type": "string", "required": true }
    },
    "block_types": {
      "ingress": {
        "nesting_mode": "list",
        "block": {
          "attributes": {
            "from_port": { "type": "number", "required": true },
            "to_port": { "type": "number", "required": true },
            "protocol": { "type": "string", "required": true },
            "cidr_blocks": { "type": ["list", "string"], "optional": true }
          }
        }
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_nested_list = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        name = mkOption { type = types.str; };
        ingress = mkOption {
          type = types.listOf (types.submodule {
            options = {
              from_port = mkOption { type = types.number; };
              to_port = mkOption { type = types.number; };
              protocol = mkOption { type = types.str; };
              cidr_blocks = mkOption {
                type = types.nullOr (types.listOf types.str);
                default = null;
              };
            };
          });
          default = [];
        };
      };
    });
    default = {};
    description = "Instances of example_nested_list";
  };
}

Usage:

{
  resource.example_nested_list.my_sg = {
    name = "web-sg";
    ingress = [
      { from_port = 80; to_port = 80; protocol = "tcp"; cidr_blocks = [ "0.0.0.0/0" ]; }
      { from_port = 443; to_port = 443; protocol = "tcp"; cidr_blocks = [ "0.0.0.0/0" ]; }
    ];
  };
}

Nested blocks (map)

Schema with a map-nesting block:

{
  "version": 0,
  "block": {
    "attributes": {
      "bucket": { "type": "string", "required": true }
    },
    "block_types": {
      "lifecycle_rule": {
        "nesting_mode": "map",
        "block": {
          "attributes": {
            "enabled": { "type": "bool", "required": true },
            "prefix": { "type": "string", "optional": true },
            "expiration_days": { "type": "number", "optional": true }
          }
        }
      }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.example_nested_map = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        bucket = mkOption { type = types.str; };
        lifecycle_rule = mkOption {
          type = types.attrsOf (types.submodule {
            options = {
              enabled = mkOption { type = types.bool; };
              prefix = mkOption {
                type = types.nullOr types.str;
                default = null;
              };
              expiration_days = mkOption {
                type = types.nullOr types.number;
                default = null;
              };
            };
          });
          default = {};
        };
      };
    });
    default = {};
    description = "Instances of example_nested_map";
  };
}

Usage:

{
  resource.example_nested_map.my_bucket = {
    bucket = "my-data-bucket";
    lifecycle_rule = {
      delete_old_logs = {
        enabled = true;
        prefix = "logs/";
        expiration_days = 30;
      };
      archive_backups = {
        enabled = true;
        prefix = "backups/";
        expiration_days = 90;
      };
    };
  };
}

Computed and deprecated attributes

Schema with metadata flags:

{
  "version": 0,
  "block": {
    "attributes": {
      "ami": { "type": "string", "required": true, "description": "AMI to use" },
      "id": { "type": "string", "computed": true, "description": "Instance ID" },
      "availability_zone": { "type": "string", "optional": true, "deprecated": true }
    }
  }
}

Generated module:

{ lib, ... }:
with lib;
{
  options.resource.aws_instance = mkOption {
    type = types.attrsOf (types.submodule {
      options = {
        ami = mkOption {
          type = types.str;
          description = "AMI to use";
        };
        id = mkOption {
          type = types.nullOr types.str;
          default = null;
          description = ''
            Instance ID

            This value is computed by the provider.
          '';
          readOnly = true;
        };
        availability_zone = mkOption {
          type = types.nullOr types.str;
          default = null;
          description = ''
            DEPRECATED: This attribute is deprecated and may be removed in a future version.
          '';
        };
      };
    });
    default = {};
    description = "Instances of aws_instance";
  };
}

Computed-only attributes get readOnly = true. Deprecated, sensitive, and write-only attributes get notes appended to their description.

Transformation summary

Terraform patternNix type
Required primitivetypes.<primitive>
Optional/computed primitivetypes.nullOr types.<primitive>
Computed-onlytypes.nullOr T + readOnly = true
list(T) / set(T)types.listOf (mapType T)
map(T)types.attrsOf (mapType T)
object({...})types.submodule { options = {...}; }
tuple([...])types.tupleOf [...]
Block, single nestingtypes.submodule { ... }, default null
Block, group nestingtypes.submodule { ... }, no default
Block, list nestingtypes.listOf (types.submodule { ... }), default []
Block, set nestingtypes.listOf (types.submodule { ... }), default []
Block, map nestingtypes.attrsOf (types.submodule { ... }), default {}
DeprecatedNote in description
SensitiveNote in description
Write-onlyNote in description

Architecture

terranix-codegen is a Haskell pipeline that transforms Terraform provider schemas into NixOS module files.

Pipeline

Provider spec       e.g. "hashicorp/aws:5.0.0"
      │
      ▼
TerraformGenerator  generates minimal .tf, runs tofu init + providers schema -json
      │
      ▼
ProviderSchema      parses JSON into Haskell ADTs (CtyType, SchemaAttribute, SchemaBlock, etc.)
      │
      ▼
TypeMapper          CtyType → hnix NExpr (e.g. CtyString → types.str)
      │
      ▼
OptionBuilder       SchemaAttribute → mkOption { type = ...; default = ...; description = ...; }
      │
      ▼
ModuleGenerator     assembles options into { lib, ... }: with lib; { options.resource.X = ...; }
      │
      ▼
FileOrganizer       writes one .nix file per resource/data source + default.nix imports

Each stage is independent and tested separately.

Key modules

ModuleFileWhat it does
ProviderSpeclib/TerranixCodegen/ProviderSpec.hsParses provider spec strings (aws, hashicorp/aws:5.0.0)
TerraformGeneratorlib/TerranixCodegen/TerraformGenerator.hsRuns tofu/terraform to extract schema JSON
ProviderSchemalib/TerranixCodegen/ProviderSchema/JSON parsing into Haskell types (aeson)
TypeMapperlib/TerranixCodegen/TypeMapper.hsgo-cty type → NixOS module type
OptionBuilderlib/TerranixCodegen/OptionBuilder.hsSchema attribute → mkOption expression
ModuleGeneratorlib/TerranixCodegen/ModuleGenerator.hsAssembles complete NixOS modules
FileOrganizerlib/TerranixCodegen/FileOrganizer.hsDirectory structure and file writing
PrettyPrintlib/TerranixCodegen/PrettyPrint.hsColorized terminal output for show command

Schema types

The Terraform provider schema is parsed into these Haskell types:

  • ProviderSchemas – top-level container mapping registry paths to providers
  • ProviderSchema – one provider’s config schema, resource schemas, and data source schemas
  • Schema – a single resource/data source, containing a SchemaBlock
  • SchemaBlock – has blockAttributes (a map of SchemaAttribute) and blockNestedBlocks (a map of SchemaBlockType)
  • SchemaAttribute – type, description, required/optional/computed flags, deprecation, sensitivity
  • SchemaBlockType – a nested block with a nesting mode (single/group/list/set/map) and an inner SchemaBlock
  • CtyType – Terraform’s type system: Bool, Number, String, Dynamic, List T, Set T, Map T, Object fields optionals, Tuple elems

Code generation approach

All Nix code is generated through hnix’s NExpr AST and pretty-printer. No string templates. This means:

  • The generated code is always syntactically valid
  • Type mappings are compositional (you can nest them freely)
  • The pretty-printer handles formatting

The final step uses nixExprToText (which calls hnix’s prettyNix) to render the AST to text.

Design decisions

NixOS modules with no config block. The generated modules only declare options. Because the option paths (resource.<type>.<name>.<attr>) exactly match the attrset structure Terranix already consumes, no transformation is needed. The module system validates the values and passes them through as-is.

One file per resource. Large providers like AWS have 1000+ resources. A single file would be unmanageable. Individual files also make git diffs clean and allow selective imports.

hnix AST instead of string templates. More verbose to write, but impossible to generate malformed Nix. Also makes testing easier – tests compare ASTs directly using hnix’s quasiquoter.

types.nullOr for optional attributes. Matches Terraform’s semantics where omitted optional attributes are treated as null/unset. Using null as the default lets optional+computed attributes fall through to provider-computed values.

Custom types.tupleOf. Terraform has typed fixed-length tuples. Nix doesn’t have a built-in equivalent, so we provide one in nix/lib/tuple.nix that validates both length and per-position types.