Fundamentals
This guide teaches you the essential patterns for writing formae. You'll learn how to structure forma files, work with properties, reference resources, and avoid common mistakes.
Prerequisites: Complete the Quick Start to install formae and deploy your first forma. This guide builds on those concepts.
Getting started with formae
Every forma file begins with the same pattern:
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
amends
tells Pkl this is a forma documentimport
brings in the formae types you'll need
Import order best practices
Follow this order to avoid confusion:
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
// External resources
import "@aws/ec2/vpc.pkl"
import "@aws/s3/bucket.pkl"
// Your modules
import "./infrastructure/network.pkl"
import "./vars.pkl"
The building blocks
Every forma is composed of three primary building blocks: description, properties, and forma. Understanding these blocks is essential for writing effective infrastructure code.
Description block (optional)
The description block provides context to users about what the forma does and can optionally prompt for confirmation before applying changes:
description {
text = """
This forma creates a VPC with subnets in the \(properties.environment.value) environment.
It will create the following resources:
- VPC with CIDR block \(properties.vpcCidr.value)
- Public and private subnets
- Internet gateway and route tables
"""
confirm = true // Prompts user for confirmation before applying
}
String Templating: Use
\(expression)
to embed values directly in strings. This works anywhere in Pkl - resource names, descriptions, property values, etc. This is standard Pkl syntax.
The confirm
flag is particularly useful for production infrastructure or destructive changes where you want an explicit approval step.
Properties block (optional)
Properties make your formae reusable and configurable. They become command-line flags automatically, making it easy for teams to customize infrastructure without editing code:
properties {
environment = new formae.Prop {
flag = "environment"
default = "dev"
}
vpcCidr = new formae.Prop {
flag = "vpc-cidr"
default = "10.0.0.0/16"
}
enableNatGateway = new formae.Prop {
flag = "enable-nat"
default = "false"
}
}
Access property values using .value
:
cidrBlock = properties.vpcCidr.value
Note: Properties are automatically exposed as CLI flags. Run
formae apply --help your-forma.pkl
to see all available properties. Learn more in Properties.
Forma block
The forma block is where you define your infrastructure resources. This is the heart of your forma - it specifies what will be created, updated, or managed:
forma {
// Define a stack to organize your resources
new formae.Stack {
label = "my-app-stack"
description = "Infrastructure for my application"
}
// Define a target for where resources will be created
new formae.Target {
label = "aws-target"
config = new aws.Config {
region = properties.region.value
}
}
// Define your infrastructure resources
local myVpc = new vpc.Vpc {
label = "main-vpc"
cidrBlock = properties.vpcCidr.value
tags {
["Name"] = "my-vpc-" + properties.environment.value
["Environment"] = properties.environment.value
}
}
myVpc
}
Every forma needs a Stack to organize resources and a Target to specify where resources are deployed.
Simplicity by design: Unlike other IaC tools, formae doesn't need outputs or providers blocks. Resources automatically expose their properties through
.res
, and targets replace traditional provider configurations.
Understanding properties in depth
Properties are the key to making your formae flexible and reusable. They're not just variables - they're a powerful abstraction layer between infrastructure code and its configuration.
How properties work
When you define a property with a flag
, it automatically becomes a command-line option:
properties {
region = new formae.Prop {
flag = "region" // Becomes --region on CLI
default = "us-east-1" // Used if not specified
}
instanceCount = new formae.Prop {
flag = "instances"
default = "2"
}
}
Use property values in your resources:
forma {
new formae.Target {
label = "aws-target"
config = new aws.Config {
region = properties.region.value // Always use .value
}
}
new s3.Bucket {
label = "app-bucket"
bucketName = "my-app-" + properties.region.value
}
}
Override from the command line:
# Use defaults
formae apply --mode reconcile main.pkl
# Override region
formae apply --mode reconcile --region us-west-2 main.pkl
# Override multiple properties
formae apply --mode reconcile --region eu-west-1 --instances 5 main.pkl
Properties for platform engineering
Properties enable platform teams to create reusable formae that developers can customize without seeing or editing infrastructure code:
// Platform team creates this forma with configurable properties
properties {
appName = new formae.Prop {
flag = "app-name"
}
environment = new formae.Prop {
flag = "env"
default = "dev"
}
databaseSize = new formae.Prop {
flag = "db-size"
default = "small"
}
}
// Infrastructure logic hidden from developers
forma {
// Complex infrastructure setup based on simple properties
// ...
}
Developers can deploy without understanding the infrastructure:
formae apply --mode reconcile --app-name myapp --env production --db-size large service.pkl
Tip: Properties are essential for abstracting infrastructure complexity in platform engineering scenarios. Learn more about this pattern in Properties.
The local pattern and resource references
Infrastructure resources often depend on each other. A subnet needs a VPC ID, a security group needs a VPC ID, and so on. The local
pattern combined with .res
handles these dependencies elegantly.
When to use local
Use local
when you need to reference a resource's properties in other resources:
forma {
// Declare a resource with local so we can reference it
local myVpc = new vpc.Vpc {
label = "main-vpc"
cidrBlock = "10.0.0.0/16"
enableDnsHostnames = true
enableDnsSupport = true
}
myVpc // Don't forget to instantiate it, else it won't be rendered
// Reference the VPC in multiple places using .res
local publicSubnet = new subnet.Subnet {
label = "public-subnet"
vpcId = myVpc.res.vpcId // Get VPC ID using .res
cidrBlock = "10.0.1.0/24"
availabilityZone = "us-east-1a"
}
publicSubnet
new subnet.Subnet {
label = "private-subnet"
vpcId = myVpc.res.vpcId // Reference same VPC
cidrBlock = "10.0.2.0/24"
availabilityZone = "us-east-1b"
}
new securitygroup.SecurityGroup {
label = "app-sg"
vpcId = myVpc.res.vpcId // Reference same VPC again
groupDescription = "Security group for application"
}
}
Understanding .res (resolvables)
The .res
suffix accesses a resource's properties. formae automatically handles:
- Dependency ordering - Resources are created in the correct order
- Property resolution - Values are injected when available
- Waiting for creation - Dependencies wait for resources to be created
This eliminates the need to manually declare dependencies or worry about ordering.
Resources without dependencies
If a resource doesn't need to be referenced, don't use local
:
forma {
// No local needed - nothing references this bucket
new bucket.Bucket {
label = "logs-bucket"
bucketName = "my-logs-bucket"
}
// No local needed - standalone resource
new bucket.Bucket {
label = "assets-bucket"
bucketName = "my-assets-bucket"
}
}
Rule of thumb: If you need to reference it, make it local
and instantiate it. If you just need to create it, don't use local
.
Deep dive: Learn more about how resolvables work and advanced patterns in Res.
Test your forma immediately
Before applying anything, check your work:
formae eval main.pkl
This shows you exactly what will be created, in JSON format. If you see something unexpected, fix it before applying.
Tip: Run
formae eval
after every significant change. It catches errors early and shows you the exact infrastructure that will be created.
Common mistakes
Incorrect property access. Properties have a .value
suffix.
// Incorrect – property is not applied
bucketName = properties.environment
// Correct 🙂
bucketName = properties.environment.value
Failing to instantiate locals. Local variables must be instantiated to appear in the output.
// Incorrect - will not be rendered
forma {
local myVpc = new vpc.Vpc {
cidrBlock = "10.0.0.0/16"
}
// myVpc is declared but never instantiated - it won't be rendered!
}
// Correct 🙂
forma {
local myVpc = new vpc.Vpc {
cidrBlock = "10.0.0.0/16"
}
myVpc // Instantiate it so it gets rendered
}
Advanced: Working with nullable types
Pkl supports nullable types for optional configurations. This is useful when creating reusable modules that accept optional parameters.
Nullable type syntax
Indicate nullable types by adding a question mark after the type name:
// A nullable string that can be null or a string value
name: String? = "Default"
// A nullable dynamic object for optional configurations
config: Dynamic? = null
Null propagation
Use the null propagation operator (?.
) to safely access properties without causing errors:
// Regular access - will error if myConfig is null
region = myConfig.region
// Null-safe access - returns null if myConfig is null
region = myConfig?.region
Default values with null coalescing
Use the null coalescing operator (??
) to provide default values when a property might be null:
// Use "us-east-1" if myConfig?.region is null
region = myConfig?.region ?? "us-east-1"
// Useful for optional properties in module functions
function database(properties: Dynamic?): dbinstance.DBInstance = new dbinstance.DBInstance {
engine = properties?.engine ?? "postgres"
port = properties?.port ?? 5432
}
What's next
You now know how to write formae with proper structure, properties, and resource references. Next, learn how to organize complex infrastructure into reusable modules:
- Modular infrastructure - Break infrastructure into focused, reusable components
- Practical example - See a complete Day-0 to Day-N workflow in action
- Workflows - Explore different deployment approaches