Example — AWS VPC
This walkthrough authors a richer template than Azure Resource Group. It exercises every advanced feature of template.json:
list(object)withmin_items- Nested
objectinside alist(object) - Deeply nested
list(object)(subnet → NACL → ingress/egress rules) optionsenums for protocol and action choicesoptional(...)Terraform syntax for non-required nested fields
The files match the shipped tpl-aws-vpc verbatim. Treat this as the canonical reference for advanced authoring.
The VPC template provisions:
- One
aws_vpc. - One or more
aws_subnet(the user defines them; at least one is required). - An optional internet gateway (one of the subnets opts in via
create_igw). - An optional NAT gateway (one of the subnets opts in via
create_nat_gateway). - Optional Network ACLs per subnet, with custom ingress/egress rules.
- Optional route tables per subnet.
1. Directory Layout
templates/
└── aws/
└── my-vpc/
├── main.tf
└── template.json
2. main.tf
The full file is long. It is broken down here into the patterns that matter for template authoring; the complete source is in the shipped template.
Provider and required-providers
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.region
}
Top-level variables
variable "region" {
description = "AWS region"
type = string
default = "eu-west-1"
}
variable "vpc_name" {
description = "Name of the VPC"
type = string
}
variable "cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "tags" {
description = "Tags to apply to resources"
type = map(string)
default = {}
}
The deeply-nested subnets variable
The interesting one. subnets is a list(object) where each element has an optional NACL object (which itself contains lists of rules) and an optional route table.
variable "subnets" {
description = "List of subnets to create"
type = list(object({
name = string
cidr = string
availability_zone = string
map_public_ip_on_launch = bool
create_igw = optional(bool, false)
create_nat_gateway = optional(bool, false)
nacl = optional(object({
ingress_rules = list(object({
rule_number = number
action = string
protocol = string
source_cidr = string
destination_cidr = string
from_port = number
to_port = number
}))
egress_rules = list(object({
rule_number = number
action = string
protocol = string
source_cidr = string
destination_cidr = string
from_port = number
to_port = number
}))
}))
route_table = optional(object({
routes = list(object({
destination_cidr = string
target_type = string
target_id = optional(string, "")
}))
}))
}))
validation {
condition = length(var.subnets) > 0
error_message = "At least one subnet must be defined."
}
}
Notes on the pattern:
optional(bool, false)andoptional(string, "")make a nested field optional with a default. The user can omit them in the UI; Terraform treats them asfalse/"".optional(object({...}))with no default makes the entire nested object optional — when omitted, the value isnulland you checkif s.nacl != nullin yourlocals.validationat the bottom enforcesmin_itemsat plan time. The same constraint is also expressed intemplate.jsonso it surfaces in the UI before plan runs.
Resources, locals, outputs
These are standard Terraform — see the full source for the resource declarations. The key idea: rich nested input → flat resources via for_each and flatten over locals.
3. template.json
This is where the advanced manifest features earn their keep. The full file ships at aws/vpc/template.json. Walk through it section by section.
Top-level
{
"id": "tpl-aws-my-vpc",
"name": "AWS VPC",
"description": "Deploy an AWS Virtual Private Cloud with configurable subnets, internet/NAT gateways, NACLs, and route tables.",
"category": "infrastructure",
"cloud_provider": "aws",
"iac_type": "terraform",
"icon": "Network",
"variables": [ /* ... */ ]
}
region with options
User-facing region selector with a curated dropdown.
{
"name": "region",
"type": "string",
"description": "AWS region",
"default": "eu-west-1",
"required": true,
"sensitive": false,
"options": [
"eu-west-1", "eu-central-1", "us-east-1", "us-east-2",
"us-west-2", "ap-southeast-1", "eu-west-2"
]
}
subnets — list(object) with min_items
The outer shape. Note min_items: 1 enforces "at least one subnet" in the UI.
{
"name": "subnets",
"type": "list(object)",
"description": "List of subnets to create",
"required": true,
"sensitive": false,
"min_items": 1,
"schema": [ /* nested fields, below */ ]
}
Subnet fields — primitives
Inside the schema:
{ "name": "name", "type": "string", "required": true, "sensitive": false },
{ "name": "cidr", "type": "string", "required": true, "sensitive": false },
{ "name": "availability_zone", "type": "string", "required": true, "sensitive": false },
{ "name": "map_public_ip_on_launch", "type": "bool", "required": true, "sensitive": false, "default": false },
{ "name": "create_igw", "type": "bool", "required": false, "sensitive": false, "default": false },
{ "name": "create_nat_gateway", "type": "bool", "required": false, "sensitive": false, "default": false }
Subnet nacl — nested object with deeply nested list(object)
The NACL is itself an object (required: false), which in turn contains two list(object) fields. Each rule has primitives and option-driven enums:
{
"name": "nacl",
"type": "object",
"description": "Optional Network ACL for this subnet. Omit to use the VPC default NACL (allow-all).",
"required": false,
"sensitive": false,
"schema": [
{
"name": "ingress_rules",
"type": "list(object)",
"description": "Inbound rules (NACLs are stateless — remember to allow return traffic in egress)",
"required": true,
"sensitive": false,
"schema": [
{ "name": "rule_number", "type": "number", "required": true, "sensitive": false, "description": "Rule priority (lowest first)" },
{ "name": "action", "type": "string", "required": true, "sensitive": false, "options": ["allow", "deny"] },
{ "name": "protocol", "type": "string", "required": true, "sensitive": false, "options": ["6", "17", "-1", "1"], "description": "6=TCP, 17=UDP, -1=all, 1=ICMP" },
{ "name": "source_cidr", "type": "string", "required": true, "sensitive": false },
{ "name": "destination_cidr", "type": "string", "required": true, "sensitive": false },
{ "name": "from_port", "type": "number", "required": true, "sensitive": false },
{ "name": "to_port", "type": "number", "required": true, "sensitive": false }
]
},
{
"name": "egress_rules",
"type": "list(object)",
"description": "Outbound rules",
"required": true,
"sensitive": false,
"schema": [ /* same shape as ingress_rules */ ]
}
]
}
This is three levels deep: subnet → nacl (object) → ingress_rules (list of object) → fields. Amnify renders this with nested groups and add/remove buttons at each list level. There is no hard depth limit.
Subnet route_table — optional object
{
"name": "route_table",
"type": "object",
"description": "Optional custom route table for this subnet. Omit to use the VPC main route table.",
"required": false,
"sensitive": false,
"schema": [
{
"name": "routes",
"type": "list(object)",
"description": "Route entries",
"required": true,
"sensitive": false,
"schema": [
{ "name": "destination_cidr", "type": "string", "required": true, "sensitive": false },
{
"name": "target_type",
"type": "string",
"required": true,
"sensitive": false,
"options": ["internet_gateway", "nat_gateway", "vpc_peering", "transit_gateway", "network_interface"]
},
{
"name": "target_id",
"type": "string",
"required": false,
"sensitive": false,
"description": "Not needed for internet_gateway or nat_gateway (auto-resolved). Required for vpc_peering, transit_gateway, network_interface."
}
]
}
]
}
A note on conditional requirements
The target_id field above is conceptually required only for some target_type values (vpc_peering, transit_gateway, network_interface) but optional for others (internet_gateway, nat_gateway, where Amnify auto-resolves the ID).
Amnify's condition field on a variable can hide or show a field based on a sibling's value, but it does not currently support multi-value conditions ("show when target_type is one of [a, b, c]"). The pattern used by the shipped template is:
- Mark
target_idasrequired: falseintemplate.json. - Document the per-
target_typerequirement in thedescription. - Enforce the rule at plan time with a Terraform
validationblock.
validation {
condition = alltrue([
for s in var.subnets : s.route_table == null ? true : alltrue([
for r in s.route_table.routes :
contains(["internet_gateway", "nat_gateway"], r.target_type) || r.target_id != ""
])
])
error_message = "target_id is required when target_type is vpc_peering, transit_gateway, or network_interface."
}
This is a common pattern: the manifest captures the structural shape, Terraform enforces cross-field semantics.
4. Validate Locally
cd templates/aws/my-vpc
terraform init -backend=false
terraform validate
For deeply nested templates, also try terraform plan against a hand-crafted terraform.tfvars.json to make sure the variable shape lines up. Example:
{
"region": "eu-west-1",
"vpc_name": "demo",
"cidr": "10.0.0.0/16",
"subnets": [
{
"name": "public-a",
"cidr": "10.0.1.0/24",
"availability_zone": "eu-west-1a",
"map_public_ip_on_launch": true,
"create_igw": true
}
],
"tags": {}
}
terraform plan -var-file=terraform.tfvars.json -backend=false
(If you have AWS credentials configured locally; otherwise validate is enough to catch type mismatches.)
5. Publish
git add templates/aws/my-vpc
git commit -m "Add custom AWS VPC template"
git push origin main
Then click Sync Templates in Amnify Deploy.
Lessons From This Template
If you take only three things from this example, take these:
- Mirror the
.tfandtemplate.jsonshapes exactly. Every nestedoptional(object({...}))in Terraform corresponds to anobjectwithrequired: falsein JSON. Everylist(object({...}))corresponds tolist(object)with aschemaarray. - Use
optionsfor enums. Anywhere your Terraform takes one of a known set of strings, expose it asoptions. The UI dropdown prevents typos and the user does not need to memorize the valid values. - Combine manifest validation with Terraform
validation. The manifest catches structural issues (missing required field, empty list); Terraformvalidationblocks catch semantic issues (cross-field rules). Use both.
Next Steps
- template.json Reference — for any field this example didn't exercise (e.g.
condition,resource_type). - Variable Types & Secrets — for
sensitivevariables, which the VPC template doesn't use.