Platform engineering

Build formae that others can use without touching the code.


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

  1. Define properties — expose only what developers need to change
  2. Map properties to resources — translate flags into infrastructure decisions
  3. 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