Platform engineering
Build formae that others can use without touching the code.
Scenarios covered:
I want to create self-service infrastructure for developers
When to use this
Use this workflow when you:
- Run a platform team and want to give developers production-ready infrastructure without them writing Pkl
- Need guardrails — developers pick options, your code enforces standards
- Support multiple teams deploying similar infrastructure with different parameters
The core idea: platform engineers write the forma with properties, developers run formae apply with flags. No Pkl knowledge required.
What you'll do
- Define properties — expose only what developers need to change
- Map properties to resources — translate flags into infrastructure decisions
- Organize with modules — encapsulate complexity behind clean interfaces
Step-by-step
Before you begin: review the baseline prerequisites, and familiarize yourself with properties and modular patterns.
Step 1: Define properties as your developer interface
Properties become CLI flags automatically. Design them as the inputs developers need:
// team-database.pkl
amends "@formae/forma.pkl"
import "@formae/formae.pkl"
properties {
team = new formae.Prop {
flag = "team"
}
env = new formae.Prop {
flag = "env"
default = "dev"
}
size = new formae.Prop {
flag = "size"
default = "small"
}
}
Developers never open the Pkl file — they interact entirely through CLI flags. See Step 5.
Step 2: Map flags to infrastructure decisions
Use the property values to drive resource configuration. This is where platform expertise lives — translating "small/medium/large" into actual instance classes, storage sizes, and backup policies:
// Map t-shirt sizes to instance config
local instanceClass = if (properties.size.value == "large") "db.r6g.xlarge"
else if (properties.size.value == "medium") "db.r6g.large"
else "db.t4g.micro"
local storageGb = if (properties.size.value == "large") 500
else if (properties.size.value == "medium") 100
else 20
local teamName = properties.team.value
local envName = properties.env.value
Step 3: Build the resources with standards baked in
Encode your organization's standards directly in the forma. Developers get compliant infrastructure without thinking about it:
forma {
new formae.Stack {
label = "\(teamName)-\(envName)"
}
new formae.Target {
label = "\(teamName)-target"
config = new aws.Config {
region = "us-east-1"
}
}
new secret.Secret {
label = "\(teamName)-db-password"
secretString = formae.value(random.id(24).toString()).opaque.setOnce
}
new dbinstance.DBInstance {
label = "\(teamName)-database"
engine = "postgres"
engineVersion = "15.4"
dbInstanceClass = instanceClass
allocatedStorage = storageGb
storageEncrypted = true // Always encrypted
multiAz = envName != "dev" // Multi-AZ for non-dev
deletionProtection = envName == "production"
masterUsername = teamName
masterUserPassword = formae.value(random.id(24).toString()).opaque.setOnce
tags {
new { key = "Team"; value = teamName }
new { key = "Environment"; value = envName }
new { key = "ManagedBy"; value = "formae" }
}
}
}
Step 4: Use modules for larger offerings
For more complex self-service offerings, use class-based modules to encapsulate entire subsystems:
// infrastructure/team_environment.pkl
import "@aws/ec2/securitygroup.pkl"
import "@aws/rds/dbinstance.pkl"
import "@aws/s3/bucket.pkl"
class TeamEnvironment {
team: String
env: String
size: String
hidden sg: securitygroup.SecurityGroup = new {
label = "\(team)-\(env)-sg"
groupDescription = "\(team) \(env) security group"
// ... ingress/egress rules baked in
}
hidden database: dbinstance.DBInstance = new {
label = "\(team)-\(env)-db"
// ... size mapping, encryption, tags
}
hidden artifacts: bucket.Bucket = new {
label = "\(team)-\(env)-artifacts"
bucketName = "\(team)-\(env)-artifacts"
// ... versioning, lifecycle rules
}
resources: Listing = new {
sg
database
artifacts
}
}
Then the forma stays clean:
import "./infrastructure/team_environment.pkl"
local environment = new team_environment.TeamEnvironment {
team = properties.team.value
env = properties.env.value
size = properties.size.value
}
forma {
new formae.Stack { label = "\(properties.team.value)-\(properties.env.value)" }
new formae.Target { label = "default"; config = new aws.Config { region = "us-east-1" } }
...environment.resources
}
Step 5: Let developers use it
The properties you defined in Step 1 automatically become CLI flags. Developers discover them with --help:
formae apply --help team-database.pkl
Then deploy using those flags — no Pkl knowledge needed:
formae apply --mode patch --watch --team payments --env staging team-database.pkl
Tips + gotchas
| Tip | Details |
|---|---|
| Use defaults generously | The fewer required flags, the easier it is for developers. --team is required, --size defaults to small. |
| Pkl constraints enforce policy | Use Pkl type annotations and when clauses for validation that runs before any cloud API call. |
Common gotcha: Treat property flags as a public API. If you rename
flag = "team"to something else later, you’ll break developer scripts and CI jobs. Fix: keep flags stable, add new flags instead of renaming, and deprecate old ones gradually.
What's next
| Goal | Guide |
|---|---|
| Automate in a pipeline | CI/CD Integration |
| Deep dive on properties | Core Concepts → Properties |
| Modular patterns | Modular infrastructure |
| Values, secrets, and stability | Core Concepts → Values |