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
| Terraform | Nix |
|---|---|
string | types.str |
number | types.number |
bool | types.bool |
dynamic | types.anything |
Collections
| Terraform | Nix | Notes |
|---|---|---|
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
| Terraform | Nix |
|---|---|
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 typemapCtyTypeToNixWithOptional– wraps intypes.nullOrwhen 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:
| Flags | Type wrapping | Default | readOnly |
|---|---|---|---|
| required | bare type | none (user must provide) | no |
| optional | types.nullOr T | null | no |
| computed only | types.nullOr T | null | yes |
| optional + computed | types.nullOr T | null | no |
Block nesting modes
Nested blocks in Terraform schemas have a nesting mode that determines how they appear in configuration:
| Mode | Nix type | Default |
|---|---|---|
single | types.submodule { ... } | null |
group | types.submodule { ... } | none |
list | types.listOf (types.submodule { ... }) | [] |
set | types.listOf (types.submodule { ... }) | [] |
map | types.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 pattern | Nix type |
|---|---|
| Required primitive | types.<primitive> |
| Optional/computed primitive | types.nullOr types.<primitive> |
| Computed-only | types.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 nesting | types.submodule { ... }, default null |
| Block, group nesting | types.submodule { ... }, no default |
| Block, list nesting | types.listOf (types.submodule { ... }), default [] |
| Block, set nesting | types.listOf (types.submodule { ... }), default [] |
| Block, map nesting | types.attrsOf (types.submodule { ... }), default {} |
| Deprecated | Note in description |
| Sensitive | Note in description |
| Write-only | Note 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
| Module | File | What it does |
|---|---|---|
| ProviderSpec | lib/TerranixCodegen/ProviderSpec.hs | Parses provider spec strings (aws, hashicorp/aws:5.0.0) |
| TerraformGenerator | lib/TerranixCodegen/TerraformGenerator.hs | Runs tofu/terraform to extract schema JSON |
| ProviderSchema | lib/TerranixCodegen/ProviderSchema/ | JSON parsing into Haskell types (aeson) |
| TypeMapper | lib/TerranixCodegen/TypeMapper.hs | go-cty type → NixOS module type |
| OptionBuilder | lib/TerranixCodegen/OptionBuilder.hs | Schema attribute → mkOption expression |
| ModuleGenerator | lib/TerranixCodegen/ModuleGenerator.hs | Assembles complete NixOS modules |
| FileOrganizer | lib/TerranixCodegen/FileOrganizer.hs | Directory structure and file writing |
| PrettyPrint | lib/TerranixCodegen/PrettyPrint.hs | Colorized 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 providersProviderSchema– one provider’s config schema, resource schemas, and data source schemasSchema– a single resource/data source, containing aSchemaBlockSchemaBlock– hasblockAttributes(a map ofSchemaAttribute) andblockNestedBlocks(a map ofSchemaBlockType)SchemaAttribute– type, description, required/optional/computed flags, deprecation, sensitivitySchemaBlockType– a nested block with a nesting mode (single/group/list/set/map) and an innerSchemaBlockCtyType– 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.