> For the complete documentation index, see llms.txt.
Skip to main content

Check out Port for yourself ➜ 

Manage workflows with IaC

Besides building workflows in the visual editor, you can manage them as code with the Port Terraform or Pulumi providers. 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.

How a workflow maps to IaC

A workflow is a graph, and both providers mirror that structure with two collections:

  • Nodes - one entry per step. Each node has an identifier and exactly one config object that determines its type.
  • Connections - one entry per edge, referencing nodes by their source and target identifiers.

Every node type available in the editor has a matching config object:

CategoryTerraformPulumi (TypeScript)
Triggersself_serve_trigger, event_trigger, schedule_triggerselfServeTrigger, eventTrigger, scheduleTrigger
Actionswebhook, kafka, upsert_entity, ai, ai_agent, integration_actionwebhook, kafka, upsertEntity, ai, aiAgent, integrationAction
Flowcondition, inputcondition, input

Connections leaving a condition or input node must set a source outlet identifier to name the branch they follow. A condition node can also have one connection marked as the fallback, taken when no outlet matches.

Naming conventions

Terraform uses repeated node and connections blocks with snake_case attributes. Pulumi takes nodes and connections arrays, using camelCase in TypeScript and snake_case in Python.

Define a workflow

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"
}
Selecting a Port API URL by account region

The port_region, port.baseUrl, portBaseUrl, port_base_url and OCEAN__PORT__BASE_URL parameters select which Port API instance to use:

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 uses ${} for its own interpolation and Pulumi resolves values in your program, Port's {{ }} syntax passes through both untouched. Encode JSON payloads with jsonencode in Terraform, or JSON.stringify and json.dumps in Pulumi, so the expressions stay quoted as strings.

For the full set of available references, see data flow.