| name | dinghy-iac |
| description | Create Infrastructure as Code using Dinghy's React TSX components. Use when the user wants to provision infrastructure (currently AWS) using Terraform/OpenTofu rendered from TSX. |
| argument-hint | ["description"] |
Define infrastructure as React TSX that renders to Terraform JSON. Apply with
OpenTofu (default) or Terraform. Configuration is data-driven — YAML provides
the data model, TSX defines the component structure.
Workflow
-
Create a TSX file in the current directory. Name it descriptively
(kebab-case, e.g. my-server.tsx, my-lambda.tsx). Each .tsx file exports
a default React component wrapped in <AwsStack>. Only import modules that
are actually used — do not generate unused imports.
-
Configure dinghy.config.yml to provide the data model for your
infrastructure. YAML keys map to component props and are auto-discovered by
composites. Most infrastructure is defined here, not in TSX props.
-
After generating files, ask the user if they want to see the changes with
dinghy tf diff. Also mention:
Next steps:
- `dinghy tf diff` — render and preview infrastructure changes
- `dinghy tf deploy` — apply infrastructure changes
- `dinghy devcontainer` — open in VSCode Devcontainer
Project Layout
my-server.tsx <- each TSX file is a separate stack
my-lambda.tsx
dinghy.config.yml <- data model: servers, sites, lambdas, roles, etc.
src/
lambdas/ <- Lambda source files (auto-discovered)
my-handler.ts
my-handler/index.ts
s3-files/ <- files to upload to S3 (auto-discovered)
bucket-name/
index.html
config/
stacks/ <- stack-level config (loaded by stack name tags)
default.yml
prod.yml
iam-role-policies/ <- IAM policies (loaded by role name tags)
default.yml <- shared policies for all roles
ec2.yml <- applies to any role with "ec2" in name
my-ec2-role.yml <- applies to exact role name
The file name (minus .tsx extension) becomes the stack name.
Core Concepts
Data model pattern
YAML configuration provides the data model. TSX defines the component structure
that consumes it. Components auto-discover configuration by convention:
| Config key | Consumed by | Description |
|---|
servers | Ec2Servers | Map of server name → instance config |
sites | CloudfrontSites | Map of domain → CloudFront config |
lambdas | LambdaFunctions | Map of function name → lambda config |
roles | IamRoles | Map of role name → IAM role config |
dynamodbTables | DynamodbTables | Map of table name → DynamoDB config |
secrets | Secrets | Map of secret name → Secrets Manager |
httpApis | HttpApis | Map of API name → HTTP API + routes |
awsProvider | AwsStack | AWS provider settings (region, etc.) |
awsStack | AwsStack | Stack settings (s3Backend, logBucket) |
AwsStack
Root container that auto-creates: AWS provider, state backend, and optional log
bucket. All infrastructure goes inside <AwsStack>.
import { AwsStack } from '@dinghy/tf-aws'
export default () => <AwsStack>{/* infrastructure here */}</AwsStack>
Composites
High-level components that create multiple Terraform resources automatically.
Import from @dinghy/tf-aws/{service}:
| Component | Import | Creates |
|---|
Ec2Servers | @dinghy/tf-aws/ec2 | EC2 + VPC + IAM + AMI discovery |
Vpc | @dinghy/tf-aws/vpc | VPC + subnets + IGW + routes + SGs |
S3Bucket | @dinghy/tf-aws/s3 | S3 + versioning + logging + file uploads |
CloudfrontSites | @dinghy/tf-aws/cloudfront | CloudFront + ACM + Route53 + OAC |
LambdaFunctions | @dinghy/tf-aws/lambda | Lambda + IAM + source bundling |
IamRoles | @dinghy/tf-aws/iam | IAM roles + policies + attachments |
IamRole | @dinghy/tf-aws/iam | Single IAM role |
IamInstanceRole | @dinghy/tf-aws/iam | IAM role + instance profile for EC2 |
DynamodbTables | @dinghy/tf-aws/dynamodb | DynamoDB tables (PAY_PER_REQUEST default, TTL) |
Secrets | @dinghy/tf-aws/secretsmanager | Empty Secrets Manager secrets (populate post-deploy) |
HttpApis | @dinghy/tf-aws/apigatewayv2 | HTTP API + Stage + per-route Integration/Route/LambdaPermission |
Read modules/composites.md for detailed props and hooks.
AWS service resources
For direct Terraform resource access beyond composites, import from
@dinghy/tf-aws/service{ServiceName}:
import { AwsInstance } from '@dinghy/tf-aws/serviceEc2'
import { AwsDbInstance } from '@dinghy/tf-aws/serviceRds'
import { DataAwsCallerIdentity } from '@dinghy/tf-aws/serviceSts'
- Resources:
Aws{ResourceName} (e.g. AwsInstance, AwsS3Bucket)
- Data sources:
DataAws{ResourceName} (e.g. DataAwsAmi)
- Hooks:
useAws{ResourceName}() to reference sibling/parent resources
- Props follow the standard Terraform AWS provider resource schemas
Convention-based file discovery
Composites auto-discover files from conventional directories:
- Lambda sources:
src/lambdas/{name}.ts or src/lambdas/{name}/index.ts —
auto-bundled with Deno and zipped for deployment
- S3 files:
s3-files/{bucket-name}/ — auto-uploaded to the S3 bucket
- IAM policies:
config/iam-role-policies/ — see below
Config file loading (name-tag matching)
Dinghy loads YAML config files from config/ directories using a name-tag
matching pattern. For a given name like foo-bar-baz, the name is split by -
and all contiguous sub-combinations are generated as tags. Files matching these
tags are loaded and deep-merged in this order:
default.yml
foo.yml
bar.yml
baz.yml
foo-bar.yml
bar-baz.yml
foo-bar-baz.yml
Later files override earlier ones. This applies to:
- Stack config (
config/stacks/ or config/): loaded by stack name
- IAM role policies (
config/iam-role-policies/): loaded by role name
- Any composite that uses
loadFilesData internally
Example: for a role named my-ec2-role, these files are loaded from
config/iam-role-policies/:
default.yml <- shared policies for all roles
my.yml <- matches "my" tag
ec2.yml <- matches "ec2" tag
role.yml <- matches "role" tag
my-ec2.yml <- matches "my-ec2" tag
ec2-role.yml <- matches "ec2-role" tag
my-ec2-role.yml <- matches full name
my-ec2-role-extra.yml <- prefix match (loaded last, highest priority)
my-ec2-role-zzz.yml <- prefix match, sorted alphabetically
This lets you share config across related names. For example, ec2.yml applies
to any role with "ec2" in its name, while default.yml applies to all roles.
After the tag-based files are merged, any file whose name starts with the full
name plus - (e.g. my-ec2-role-*.yml) is also loaded, sorted alphabetically,
and deep-merged on top. Use this to split per-name overrides across multiple
files (e.g. one per concern) without touching the base my-ec2-role.yml.
Configuration Reference
dinghy.config.yml
awsProvider:
region: eu-west-1
awsStack:
title: My Stack Title
s3Backend: true
logBucket: true
s3Backend:
bucket: my-unique-backend-bucket
servers:
my-server:
instance_type: t3.nano
linuxFlavor: al2023
enableSsm: true
associate_public_ip_address: true
root_block_device:
volume_size: 100
lambdas:
my-handler:
runtime: nodejs24.x
handler: index.handler
memory_size: 256
timeout: 60
external: ['@aws-sdk/*']
environment:
variables:
NODE_ENV: production
dynamodbTables:
events-dedup:
hash_key: pk
ttl:
attribute_name: expires_at
secrets:
my-app/credentials:
description: Slack credentials JSON
httpApis:
my-app:
routes:
'POST /webhook':
lambda: my-handler
'POST /events':
lambda: my-handler
roles:
my-role:
assume_role_service: ec2.amazonaws.com
my-lambda-role:
assume_role_service: lambda.amazonaws.com
sites:
example.com:
origins:
site_root:
target: 's3://my-origin-bucket'
redirectFromNames:
- '*.example.com'
certVersions:
- '1'
IAM role policies
Policy files in config/iam-role-policies/ are loaded using
name-tag matching on the role name.
Each file contains named policy groups:
shared-buckets:
- permission: readonly
buckets:
- my-shared-bucket
managed-policies:
- name: AmazonEC2ContainerRegistryPullOnly
admin-buckets:
- permission: admin
buckets:
- my-admin-bucket
Policy types:
- Managed policies:
{ name: "PolicyName" } or
{ name: "arn:aws:iam::aws:policy/..." }
- S3 bucket policies:
{ permission: readonly|readwrite|admin, buckets: [...] }
- Statement policies:
{ actions: [...], resources: [...], effect: Allow }
Examples
EC2 server
import { AwsStack } from '@dinghy/tf-aws'
import { Ec2Servers } from '@dinghy/tf-aws/ec2'
export default () => (
<AwsStack>
<Ec2Servers />
</AwsStack>
)
servers:
my-server:
instance_type: t3.nano
Auto-creates: VPC with public subnet, security group (HTTP/HTTPS), IAM instance
profile with SSM, latest AMI discovery.
Lambda function
import { AwsStack } from '@dinghy/tf-aws'
import { LambdaFunctions } from '@dinghy/tf-aws/lambda'
export default () => (
<AwsStack>
<LambdaFunctions />
</AwsStack>
)
Lambda sources are auto-discovered from src/lambdas/. Each .ts file or
index.ts in a subfolder becomes a Lambda function:
src/lambdas/
my-handler.ts <- becomes lambda "my-handler"
api-handler/index.ts <- becomes lambda "api-handler"
CloudFront site
import { MoveToHere } from '@dinghy/base-components'
import { AwsStack } from '@dinghy/tf-aws'
import { CloudfrontSites } from '@dinghy/tf-aws/cloudfront'
export default () => (
<AwsStack infrastructure={<MoveToHere includes='AwsRoute53Zone' />}>
<CloudfrontSites />
</AwsStack>
)
awsStack:
s3Backend: true
logBucket: true
awsProvider:
region: eu-west-1
sites:
example.com:
origins:
site_root:
target: 's3://my-origin-bucket'
redirectFromNames:
- '*.example.com'
certVersions:
- '1'
IAM roles
import { AwsStack } from '@dinghy/tf-aws'
import { IamRoles } from '@dinghy/tf-aws/iam'
export default () => (
<AwsStack>
<IamRoles />
</AwsStack>
)
roles:
my-ec2-role:
my-lambda-role:
assume_role_service: lambda.amazonaws.com
Policies go in config/iam-role-policies/{role-name}.yml.
For named (managed) policies, use the bare policy name when it lives under
arn:aws:iam::aws:policy/, or the full ARN when it lives elsewhere (e.g.
service-role/):
basic-execution:
- name: arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
ec2-pull:
- name: AmazonEC2ContainerRegistryPullOnly
HTTP API receiver pattern (Lambda + DDB + Secrets + API GW)
A complete event-receiver stack (e.g. Slack webhook) using all the new
composites:
import { Shape } from '@dinghy/base-components'
import { AwsStack } from '@dinghy/tf-aws'
import { LambdaFunctions } from '@dinghy/tf-aws/lambda'
import { IamRoles } from '@dinghy/tf-aws/iam'
import { DynamodbTables } from '@dinghy/tf-aws/dynamodb'
import { Secrets } from '@dinghy/tf-aws/secretsmanager'
import { HttpApis } from '@dinghy/tf-aws/apigatewayv2'
export default function MyReceiver() {
return (
<AwsStack>
<LambdaFunctions />
<Shape _title='Support' _direction='vertical'>
<IamRoles />
<DynamodbTables />
<Secrets />
<HttpApis />
</Shape>
</AwsStack>
)
}
roles:
my-receiver-role:
assume_role_service: lambda.amazonaws.com
lambdas:
my-receiver:
role: '${aws_iam_role.awsiamrole_myreceiverrole.arn}'
architectures: [arm64]
environment:
variables:
DEDUP_TABLE: my-receiver-dedup
CONFIG_SECRET_NAME: my-app/credentials
dynamodbTables:
my-receiver-dedup:
hash_key: pk
ttl:
attribute_name: expires_at
secrets:
my-app/credentials:
description: Receiver credentials JSON
httpApis:
my-receiver:
routes:
'POST /webhook':
lambda: my-receiver
The receiver source at src/lambdas/my-receiver/index.ts should:
- Use bare
@aws-sdk/* imports (no npm: prefix); the engine's
deno.jsonc import map provides the version pins, and dinghy externalizes
them so the bundle stays tiny (typically a few KB).
- Verify Slack/HMAC signatures with
node:crypto — no need for @slack/bolt
for a thin receiver.
Writing your own composite
When a stack needs a resource type Dinghy doesn't already have a composite for
(SQS, SNS, EventBridge, AppSync, …), build the composite under
/path/to/dinghy/core/tf-aws/src/composites/{service}/ rather than dropping raw
Aws* resources into the app TSX. Three files per composite:
composites/{service}/
{Composite}.tsx -- React function: takes NodeProps, calls parse{Composite},
maps each entry to <Aws*> from ../../services/
types.ts -- zod schema (extends underlying service InputSchema)
+ parse{Composite}(input?) reading getRenderOptions().{key}
index.ts -- re-export Composite + parse fn + Type
Then add a path entry to core/tf-aws/deno.jsonc exports:
"./{name}": "./src/composites/{name}/index.ts".
Schema extension pattern (mirror composites/lambda/types.ts):
import { AwsFooInputSchema } from '../../services/foo/AwsFoo.tsx'
const FooSchema = AwsFooInputSchema.extend({
})
const FoosSchema = z.record(z.string(), FooSchema.loose().partial())
.loose().partial() at the record level lets the YAML map use the resource
name as the key with all fields optional — name is filled in from the key in the
parse function (entry.name ??= mapKey). .partial() preserves zod
.default() values, so prefer schema defaults over parse-time ??= fallbacks.
Cross-resource references inside composites:
const { fooBar } = useAwsFooBar(toId(name))
return <AwsThing prop={fooBar.id} />
return <AwsThing prop={() => 'prefix/' + (fooBar.id as any)() + '/x'} />
The proxy returned by useAws*() returns a function per property; bare access
works because deepResolve recurses through functions. String concatenation
forces stringification of the function source, so call the proxy property as
(proxy.attr as any)() inside the outer thunk to force the actual TF
interpolation string.
Override pattern for testing/customization — every Aws* should be
lookup-able via _components:
const FooComponent = props._components?.foo as typeof AwsFoo || AwsFoo
Naming: drop Aws prefix, decapitalize the rest (AwsFooBar →
_components?.fooBar). Place the lookup at the top of the composite so all
sub-components (defined as inner closures) inherit it via lexical scope.
Spread pattern for passing user data through:
<AwsFoo _id={...} _title={...} {...item} />
Service InputSchema.parse strips unknown keys, so composite-specific fields
(e.g. lambda on a route entry) flow through the spread harmlessly. Order
matters: put composite-hardcoded values after {...item} if you want them
to win, before if you want users to be able to override them via YAML.
Commands
| Command | Description |
|---|
dinghy render | Render TSX to Terraform JSON |
dinghy render <stack> | Render a specific stack |
dinghy tf diff [stack] | Render and preview infrastructure changes |
dinghy tf init [stack] | Initialize Terraform/OpenTofu |
dinghy tf plan [stack] | Preview infrastructure changes |
dinghy tf deploy [stack] | Apply infrastructure changes |
dinghy tf destroy [stack] | Destroy infrastructure |
dinghy tf bash [stack] | Open bash in Terraform container |
dinghy devcontainer | Open in VSCode Devcontainer |
Import Path Reference
| Package | Import path | Purpose |
|---|
| Foundation | @dinghy/tf-aws | AwsStack, AwsProvider, S3Backend, LogBucket |
| Composites | @dinghy/tf-aws/{service} | Ec2Servers, Vpc, S3Bucket, CloudfrontSites, LambdaFunctions, IamRoles, DynamodbTables, Secrets, HttpApis |
| Services | @dinghy/tf-aws/service{Name} | AwsInstance, AwsDbInstance, DataAwsAmi, etc. |
| Common | @dinghy/tf-common | Output, Backend, LocalFile, ArchiveFile |
| Base | @dinghy/base-components | Shape (for diagram-only grouping), MoveToHere |
Concrete composite paths:
| Service | Path |
|---|
| ec2 | @dinghy/tf-aws/ec2 |
| vpc | @dinghy/tf-aws/vpc |
| s3 | @dinghy/tf-aws/s3 |
| cloudfront | @dinghy/tf-aws/cloudfront |
| lambda | @dinghy/tf-aws/lambda |
| iam | @dinghy/tf-aws/iam |
| route53 | @dinghy/tf-aws/route53 |
| ecs | @dinghy/tf-aws/ecs |
| apigatewayv2 | @dinghy/tf-aws/apigatewayv2 |
| dynamodb | @dinghy/tf-aws/dynamodb |
| secretsmanager | @dinghy/tf-aws/secretsmanager |
Loading Module Details
When you need detailed props and hooks for dinghy-specific components, read the
corresponding file from the modules/ subdirectory of this skill directory:
| Category | File | Key Components |
|---|
| Foundation | modules/foundation.md | AwsStack, AwsProvider, S3Backend, LogBucket, S3BackendOutputs |
| Composites | modules/composites.md | Ec2Servers, Vpc, S3Bucket, CloudfrontSites, LambdaFunctions, IamRole, IamRoles |
| Common | modules/tf-common.md | Output, Backend, LocalBackend, LocalFile, ArchiveFile, DataArchiveFile |
For AWS service resources (@dinghy/tf-aws/service{Name}), use your knowledge
of the Terraform AWS provider — the component props match the standard Terraform
resource schemas.