Target
A target defines where resources should be created and managed. It tells formae the destination for changes and the source for discovery. Think of it as specifying which cloud account, region, or environment your infrastructure should live in.
Important: Single Agent Per Target
A single target should only ever be managed by one formae agent. Running multiple agents that share targets will result in unpredictable behavior. See the agent documentation for more details.
How targets work
Every forma needs at least one target. For AWS, a target specifies the region where resources should be created, and optionally includes account ID and credentials.
When you define a target in a forma, it becomes the default for all resources unless a resource explicitly references a different target. This allows you to:
- Deploy all resources to a single region/account
- Deploy different resources to different regions in the same forma
- Support multi-region deployments
Examples
Single target for all resources
Most formae use a single target that applies to all resources:
forma {
// Define the target
new formae.Target {
label = "my-aws-target"
config = new aws.Config {
region = "us-east-1"
}
}
// All resources use the target above by default
new bucket.Bucket {
label = "my-bucket"
bucketName = "my-app-bucket"
}
new vpc.VPC {
label = "my-vpc"
cidrBlock = "10.0.0.0/16"
}
}
Multiple targets in one forma
For multi-region deployments, you can define multiple targets and have different resources use different targets:
forma {
// US East target
local usEastTarget = new formae.Target {
label = "us-east-target"
config = new aws.Config {
region = "us-east-1"
}
}
usEastTarget
// EU West target
local euWestTarget = new formae.Target {
label = "eu-west-target"
config = new aws.Config {
region = "eu-west-1"
}
}
euWestTarget
// This bucket goes to US East (default is first target)
new bucket.Bucket {
label = "us-bucket"
bucketName = "my-us-bucket"
}
// This bucket explicitly goes to EU West
new bucket.Bucket {
label = "eu-bucket"
bucketName = "my-eu-bucket"
target = euWestTarget.res
}
}
Using properties for flexible targets
Make your targets configurable using properties:
properties {
region = new formae.Prop {
flag = "region"
default = "us-east-1"
}
}
forma {
new formae.Target {
label = "dynamic-target"
config = new aws.Config {
region = properties.region.value
}
}
// Resources inherit the configurable target
new bucket.Bucket {
label = "my-bucket"
bucketName = "my-bucket-" + properties.region.value
}
}
Then override from the command line:
formae apply --mode reconcile --region eu-west-1 main.pkl
Target and discovery
When discovery is enabled, you control which targets formae scans by setting the discoverable field:
forma {
// This target participates in discovery
new formae.Target {
label = "prod-us-east-1"
discoverable = true // Scan this target for resources
config = new aws.Config {
region = "us-east-1"
}
}
// This target does not participate in discovery
new formae.Target {
label = "staging-us-west-2"
discoverable = false // Don't scan (this is the default)
config = new aws.Config {
region = "us-west-2"
}
}
}
Only targets with discoverable = true will be scanned during discovery runs. This allows you to selectively enable discovery for specific accounts or regions while excluding others.
Applying a target
To register a target for discovery or deployment, apply it with reconcile mode:
formae apply --mode reconcile target.pkl
Safety note: Applying a target-only forma (containing just targets, no stack or resources) with --mode reconcile is safe and will not destroy any existing infrastructure. Reconcile mode only affects managed resources within the same stack. Since a target-only forma has no stack, there are no resources to reconcile.
This is the standard workflow for enabling discovery:
- Create a forma with a discoverable target
- Apply it with
--mode reconcileto register the target - Discovery runs automatically and finds existing resources (unmanaged)
- Extract and apply discovered resources to bring them under management
Target resolvables
Targets can resolve their configuration from resource properties at apply time. This is useful when one plugin provides infrastructure that another plugin needs to connect to — for example, a Docker Compose stack that exposes endpoints which a Grafana target uses.
import "@compose/compose.pkl"
import "@grafana/grafana.pkl"
// The compose stack exposes endpoints after creation
local lgtmStack = new compose.Stack {
label = "lgtm"
projectName = "formae-observability"
composeFile = #"""
services:
lgtm:
image: grafana/otel-lgtm:latest
ports:
- "3000:3000"
"""#
}
// The Grafana target resolves its endpoint from the compose stack
local grafanaTarget = new formae.Target {
label = "grafana"
namespace = "GRAFANA"
config = new Mapping {
["Type"] = "Grafana"
["Endpoints"] = lgtmStack.res.endpoints // resolved at apply time
["EndpointKey"] = "lgtm:3000"
}
}
grafanaTarget
When the forma is applied, formae:
- Creates the compose stack first (because the target depends on it)
- Reads the stack's
endpointsproperty (e.g.,{"lgtm:3000": "http://localhost:3000"}) - Injects the resolved value into the target config
- Creates the target with the final config
- Creates resources on that target (dashboards, folders, etc.)
This ordering is automatic — formae builds a dependency graph from the resolvable references and executes operations in the correct order.
Cross-forma references
You can reference resources from a different forma using formae.Resolvable directly. This is useful when one forma manages infrastructure and another manages applications on top of it:
// In a separate forma, reference the existing compose stack by label, stack, and type
local monitoringTarget = new formae.Target {
label = "monitoring"
namespace = "GRAFANA"
config = new Mapping {
["Endpoints"] = new formae.Resolvable {
label = "lgtm"
stack = "observability"
type = "Docker::Compose::Stack"
property = "endpoints"
}
}
}
For a complete working example, see the Grafana plugin's observability example.
Target replace
When a target's immutable configuration changes (e.g., switching a region or changing a connection endpoint), formae replaces the target and all managed resources in the correct order:
- Delete all resources on the target
- Delete the target
- Create the target with new config
- Recreate all resources
In reconcile mode, stacks not present in the forma are preserved. In patch mode, all managed resources on the target are replaced.
Portable resources
Resource schemas can declare portable = true to indicate a resource type can be recreated on a different target without region or availability-zone dependencies. During target replace, only portable resources are recreated — non-portable resources require manual intervention.
Per-field config mutability
By default, any change to a target's configuration triggers a full target replace — all resources are deleted and recreated. However, not all config fields are equal. Changing an AWS profile is a lightweight credential switch, while changing a region means moving to a different data center.
Plugin authors can annotate individual config fields as mutable or immutable using the @formae.ConfigFieldHint annotation in their PKL schema:
open class Config {
hidden fixed type: String = "AWS"
@formae.ConfigFieldHint { createOnly = false }
hidden profile: String?
@formae.ConfigFieldHint { createOnly = true }
hidden region: Region
fixed Type: String = type
fixed Profile: String? = profile
fixed Region: Region = region
}
createOnly = false— the field is mutable. Changing it produces an in-place target update without recreating resources.createOnly = true— the field is immutable. Changing it triggers a full target replace (existing behavior).
Fields without the @formae.ConfigFieldHint annotation are treated as immutable. This is the safe default — a plugin only opts into in-place updates for fields it explicitly marks as mutable.
When you run formae apply --simulate, the output now shows which config fields changed and how (added, changed, removed), so you can verify which fields will trigger a replace and which will update in place.
!!! note Per-field config mutability is backwards compatible. Plugins that have not yet adopted the annotation continue to work exactly as before — all config changes trigger a target replace.
See the schema reference for annotation details and the individual plugin pages for which fields are mutable.
Deleting a target
To remove a target that is no longer needed, create a target-only forma and use destroy:
formae destroy target.pkl
A target-only forma (containing just targets, no resources) always deletes the declared targets. formae cascade-deletes all managed resources in the target before removing the target itself.
Targets during forma destroy
When you destroy a forma that has both targets and resources, formae preserves plain targets (like cloud region targets) because they exist independently. Only targets whose configuration depends on resources being destroyed (via resolvable references) are automatically deleted.
For example, when destroying the observability forma above:
- The
dockertarget survives (plain config, no dependencies) - The
grafanatarget is deleted (its config has a$refto the compose stack's endpoints)
To force deletion of a surviving target, destroy it separately using a target-only forma.
Cascade delete
When a resource is destroyed and an external target (from a different forma) depends on that resource via a $ref, formae detects the dependency and reports it:
$ formae destroy --yes observability.pkl
Error: This operation would cascade delete additional resources.
The following targets will be cascade-deleted:
• monitoring-grafana (depends on lgtm)
To proceed with cascade deletes, use --on-dependents=cascade
Use --on-dependents=cascade to proceed, or --simulate to preview what would happen. By default, formae aborts to prevent accidental deletion of infrastructure managed by other formae.
Best practices
Use shared targets in vars.pkl:
// vars.pkl
target: formae.Target = new formae.Target {
label = "default-aws-target"
config = new aws.Config {
region = properties.region.value
}
}
Then import and use in your formae:
import "./vars.pkl"
forma {
vars.target // Use the shared target
// Your resources here
}
This pattern keeps target configuration consistent across multiple formae. Learn more in Modular infrastructure.
What's next
- Res - How resource property resolution works
- Forma - Understand the complete forma structure
- Stack - Learn how stacks organize resources
- Discovery - See how targets enable resource discovery
- Grafana plugin - See target resolvables in action with the observability example
- Docker Compose plugin - Deploy compose stacks with resolvable endpoints