Skip to main content

Example — AWS VPC

This walkthrough authors a richer template than Azure Resource Group. It exercises every advanced feature of template.json:

  • list(object) with min_items
  • Nested object inside a list(object)
  • Deeply nested list(object) (subnet → NACL → ingress/egress rules)
  • options enums for protocol and action choices
  • optional(...) 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) and optional(string, "") make a nested field optional with a default. The user can omit them in the UI; Terraform treats them as false / "".
  • optional(object({...})) with no default makes the entire nested object optional — when omitted, the value is null and you check if s.nacl != null in your locals.
  • validation at the bottom enforces min_items at plan time. The same constraint is also expressed in template.json so 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"
]
}

subnetslist(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_id as required: false in template.json.
  • Document the per-target_type requirement in the description.
  • Enforce the rule at plan time with a Terraform validation block.
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:

  1. Mirror the .tf and template.json shapes exactly. Every nested optional(object({...})) in Terraform corresponds to an object with required: false in JSON. Every list(object({...})) corresponds to list(object) with a schema array.
  2. Use options for enums. Anywhere your Terraform takes one of a known set of strings, expose it as options. The UI dropdown prevents typos and the user does not need to memorize the valid values.
  3. Combine manifest validation with Terraform validation. The manifest catches structural issues (missing required field, empty list); Terraform validation blocks catch semantic issues (cross-field rules). Use both.

Next Steps