Manage workflows with IaC
Besides building workflows in the visual editor, you can manage them as code with the Port Terraform provider. This lets you review workflow changes in pull requests, promote them across environments, and keep them in the same repository as the rest of your infrastructure.
Set up the provider
Configure the port-labs/port-labs provider with your Port credentials:
terraform {
required_providers {
port = {
source = "port-labs/port-labs"
version = "~> 2.0"
}
}
}
provider "port" {
client_id = "PORT_CLIENT_ID" # or set the env var PORT_CLIENT_ID
secret = "PORT_CLIENT_SECRET" # or set the env var PORT_CLIENT_SECRET
base_url = "https://api.port.io"
}
The port_region, port.baseUrl, portBaseUrl, port_base_url and OCEAN__PORT__BASE_URL parameters select which Port API instance to use:
- EU (app.port.io) →
https://api.port.io - US (app.us.port.io) →
https://api.us.port.io
How a workflow maps to Terraform
A workflow is a graph, and the port_workflow resource mirrors that structure with two repeatable blocks:
node- one block per step. Each node has anidentifierand exactly one config block that determines its type.connections- one block per edge, referencing nodes by theirsource_identifierandtarget_identifier.
Every node type available in the editor has a matching block:
| Category | Blocks |
|---|---|
| Triggers | self_serve_trigger, event_trigger, schedule_trigger |
| Actions | webhook, kafka, upsert_entity, ai, ai_agent, integration_action |
| Flow | condition, input |
Connections leaving a condition or input node must set source_outlet_identifier to name the branch they follow. A condition node can also have one connection marked fallback = true, taken when no outlet matches.
Self-service workflow
Let's define a workflow triggered from a user-submitted form, which collects inputs, pauses for an approval, deploys through a webhook, and records the result on the service entity:
Self-service workflow example (click to expand)
resource "port_workflow" "deploy_service" {
identifier = "deploy-service"
title = "Deploy service"
description = "Collects deployment inputs, asks for approval and deploys"
category = "engineering"
node {
identifier = "trigger"
title = "Deploy request"
self_serve_trigger {
action_card_button_text = "Deploy"
execute_action_button_text = "Deploy"
user_inputs {
user_properties = {
string_props = {
"service" = {
title = "Service"
required = true
}
}
number_props = {
"min_replicas" = {
title = "Minimum replicas"
default = 1
}
"max_replicas" = {
title = "Maximum replicas"
default = 3
}
}
}
# Evaluated when the form is submitted. When the form is split into
# steps, move the rules into the individual steps instead.
validations = [
{
constraint = ".form.max_replicas >= .form.min_replicas"
message = "Maximum replicas must be greater than or equal to minimum replicas"
},
]
}
permissions {
roles = ["Member"]
}
}
}
node {
identifier = "approval"
input {
description = "Approve this deployment?"
user_inputs {
buttons = [
{
identifier = "approve"
label = "Approve"
variant = "PRIMARY"
},
{
identifier = "reject"
label = "Reject"
variant = "DANGER"
},
]
}
outlets {
identifier = "approve"
title = "Approved"
num_of_responders = 1
}
outlets {
identifier = "reject"
title = "Rejected"
num_of_responders = 1
}
responders {
roles = ["Admin"]
}
}
}
node {
identifier = "deploy"
verbose = true
links = ["https://ci.example.com/runs/{{ .result.runId }}"]
webhook {
url = "https://ci.example.com/deploy"
method = "POST"
body = jsonencode({
service = "{{ .outputs.trigger.inputs.service }}"
min_replicas = "{{ .outputs.trigger.inputs.min_replicas }}"
max_replicas = "{{ .outputs.trigger.inputs.max_replicas }}"
})
}
}
node {
identifier = "record"
upsert_entity {
blueprint_identifier = "service"
mapping {
identifier = "{{ .outputs.trigger.inputs.service }}"
title = "{{ .outputs.trigger.inputs.service }}"
properties = jsonencode({ last_deployed_at = "{{ .run.completedAt }}" })
}
}
}
connections {
source_identifier = "trigger"
target_identifier = "approval"
}
# Only the "approve" branch continues to the deploy step.
connections {
source_identifier = "approval"
target_identifier = "deploy"
source_outlet_identifier = "approve"
}
connections {
source_identifier = "deploy"
target_identifier = "record"
}
}
Event-driven workflow with branching
This workflow reacts to catalog events instead of a form. It triggers when a production service changes, then branches on a JQ expression to either raise an alert or summarize the change with AI:
Event-driven workflow example (click to expand)
resource "port_workflow" "audit_service_changes" {
identifier = "audit-service-changes"
title = "Audit service changes"
node {
identifier = "trigger"
event_trigger {
type = "ENTITY_UPDATED"
blueprint_identifier = "service"
condition {
expressions = [".diff.after.properties.tier == \"production\""]
combinator = "and"
}
}
}
node {
identifier = "branch"
condition {
outlets {
identifier = "owner_missing"
title = "Owner missing"
expression = ".outputs.trigger.diff.after.properties.owner == null"
status_label {
text = "Missing owner"
variant = "alert"
}
}
}
}
node {
identifier = "alert"
webhook {
url = "https://alerts.example.com/service-owner-missing"
on_failure = "continue"
}
}
node {
identifier = "summarize"
ai {
user_prompt = "Summarize the change to {{ .outputs.trigger.diff.after.identifier }}"
system_prompt = "You are a concise release auditor."
tools = ["get_.*"]
}
}
connections {
source_identifier = "trigger"
target_identifier = "branch"
}
connections {
source_identifier = "branch"
target_identifier = "alert"
source_outlet_identifier = "owner_missing"
}
# Taken when no outlet expression matches.
connections {
source_identifier = "branch"
target_identifier = "summarize"
fallback = true
}
}
Passing data between nodes
Nodes reference each other's output with {{ }} template expressions, resolved when the node runs. The examples above use {{ .outputs.trigger.inputs.service }} to read a form input and {{ .result.runId }} to read the current node's result.
Because Terraform also uses ${} for its own interpolation, Port's {{ }} syntax passes through untouched. Wrap JSON payloads in jsonencode so the expressions stay quoted as strings.
For the full set of available references, see data flow.