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
identifierand 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:
| Category | Terraform | Pulumi (TypeScript) |
|---|---|---|
| Triggers | self_serve_trigger, event_trigger, schedule_trigger | selfServeTrigger, eventTrigger, scheduleTrigger |
| Actions | webhook, kafka, upsert_entity, ai, ai_agent, integration_action | webhook, kafka, upsertEntity, ai, aiAgent, integrationAction |
| Flow | condition, input | condition, 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.
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
- Terraform
- Pulumi
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
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)
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)
The Pulumi provider is generated from the Terraform provider, so it exposes the same Workflow resource and the same node types.
Set up the provider
Install the Port package and configure your credentials:
# TypeScript
npm install @port-labs/port
# Python
pip install port-pulumi
Set your credentials as Pulumi config values or environment variables:
pulumi config set --secret port:clientId <PORT_CLIENT_ID>
pulumi config set --secret port:secret <PORT_CLIENT_SECRET>
pulumi config set port:baseUrl 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
Self-service workflow
This workflow collects inputs from a form, pauses for an approval, deploys through a webhook, and records the result on the service entity:
- TypeScript
- Python
Self-service workflow example (Click to expand)
import * as pulumi from "@pulumi/pulumi";
import * as port from "@port-labs/port";
export const deployService = new port.Workflow("deployService", {
identifier: "deploy-service",
title: "Deploy service",
description: "Collects deployment inputs, asks for approval and deploys",
category: "engineering",
nodes: [
{
identifier: "trigger",
title: "Deploy request",
selfServeTrigger: {
actionCardButtonText: "Deploy",
executeActionButtonText: "Deploy",
userInputs: {
userProperties: {
stringProps: {
service: {
title: "Service",
required: true,
},
},
numberProps: {
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"],
},
},
},
{
identifier: "approval",
input: {
description: "Approve this deployment?",
userInputs: {
buttons: [
{ identifier: "approve", label: "Approve", variant: "PRIMARY" },
{ identifier: "reject", label: "Reject", variant: "DANGER" },
],
},
outlets: [
{ identifier: "approve", title: "Approved", numOfResponders: 1 },
{ identifier: "reject", title: "Rejected", numOfResponders: 1 },
],
responders: {
roles: ["Admin"],
},
},
},
{
identifier: "deploy",
verbose: true,
links: ["https://ci.example.com/runs/{{ .result.runId }}"],
webhook: {
url: "https://ci.example.com/deploy",
method: "POST",
body: JSON.stringify({
service: "{{ .outputs.trigger.inputs.service }}",
min_replicas: "{{ .outputs.trigger.inputs.min_replicas }}",
max_replicas: "{{ .outputs.trigger.inputs.max_replicas }}",
}),
},
},
{
identifier: "record",
upsertEntity: {
blueprintIdentifier: "service",
mapping: {
identifier: "{{ .outputs.trigger.inputs.service }}",
title: "{{ .outputs.trigger.inputs.service }}",
properties: JSON.stringify({ last_deployed_at: "{{ .run.completedAt }}" }),
},
},
},
],
connections: [
{
sourceIdentifier: "trigger",
targetIdentifier: "approval",
},
// Only the "approve" branch continues to the deploy step.
{
sourceIdentifier: "approval",
targetIdentifier: "deploy",
sourceOutletIdentifier: "approve",
},
{
sourceIdentifier: "deploy",
targetIdentifier: "record",
},
],
});
Self-service workflow example (Click to expand)
import json
import pulumi
import port_pulumi as port
deploy_service = port.Workflow(
"deployService",
identifier="deploy-service",
title="Deploy service",
description="Collects deployment inputs, asks for approval and deploys",
category="engineering",
nodes=[
{
"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"],
},
},
},
{
"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},
{"identifier": "reject", "title": "Rejected", "num_of_responders": 1},
],
"responders": {
"roles": ["Admin"],
},
},
},
{
"identifier": "deploy",
"verbose": True,
"links": ["https://ci.example.com/runs/{{ .result.runId }}"],
"webhook": {
"url": "https://ci.example.com/deploy",
"method": "POST",
"body": json.dumps({
"service": "{{ .outputs.trigger.inputs.service }}",
"min_replicas": "{{ .outputs.trigger.inputs.min_replicas }}",
"max_replicas": "{{ .outputs.trigger.inputs.max_replicas }}",
}),
},
},
{
"identifier": "record",
"upsert_entity": {
"blueprint_identifier": "service",
"mapping": {
"identifier": "{{ .outputs.trigger.inputs.service }}",
"title": "{{ .outputs.trigger.inputs.service }}",
"properties": json.dumps({"last_deployed_at": "{{ .run.completedAt }}"}),
},
},
},
],
connections=[
{
"source_identifier": "trigger",
"target_identifier": "approval",
},
# Only the "approve" branch continues to the deploy step.
{
"source_identifier": "approval",
"target_identifier": "deploy",
"source_outlet_identifier": "approve",
},
{
"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:
- TypeScript
- Python
Event-driven workflow example (click to expand)
import * as pulumi from "@pulumi/pulumi";
import * as port from "@port-labs/port";
export const auditServiceChanges = new port.Workflow("auditServiceChanges", {
identifier: "audit-service-changes",
title: "Audit service changes",
nodes: [
{
identifier: "trigger",
eventTrigger: {
type: "ENTITY_UPDATED",
blueprintIdentifier: "service",
condition: {
expressions: ['.diff.after.properties.tier == "production"'],
combinator: "and",
},
},
},
{
identifier: "branch",
condition: {
outlets: [
{
identifier: "owner_missing",
title: "Owner missing",
expression: ".outputs.trigger.diff.after.properties.owner == null",
statusLabel: {
text: "Missing owner",
variant: "alert",
},
},
],
},
},
{
identifier: "alert",
webhook: {
url: "https://alerts.example.com/service-owner-missing",
onFailure: "continue",
},
},
{
identifier: "summarize",
ai: {
userPrompt: "Summarize the change to {{ .outputs.trigger.diff.after.identifier }}",
systemPrompt: "You are a concise release auditor.",
tools: ["get_.*"],
},
},
],
connections: [
{
sourceIdentifier: "trigger",
targetIdentifier: "branch",
},
{
sourceIdentifier: "branch",
targetIdentifier: "alert",
sourceOutletIdentifier: "owner_missing",
},
// Taken when no outlet expression matches.
{
sourceIdentifier: "branch",
targetIdentifier: "summarize",
fallback: true,
},
],
});
Event-driven workflow example (click to expand)
import pulumi
import port_pulumi as port
audit_service_changes = port.Workflow(
"auditServiceChanges",
identifier="audit-service-changes",
title="Audit service changes",
nodes=[
{
"identifier": "trigger",
"event_trigger": {
"type": "ENTITY_UPDATED",
"blueprint_identifier": "service",
"condition": {
"expressions": ['.diff.after.properties.tier == "production"'],
"combinator": "and",
},
},
},
{
"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",
},
},
],
},
},
{
"identifier": "alert",
"webhook": {
"url": "https://alerts.example.com/service-owner-missing",
"on_failure": "continue",
},
},
{
"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",
},
{
"source_identifier": "branch",
"target_identifier": "alert",
"source_outlet_identifier": "owner_missing",
},
# Taken when no outlet expression matches.
{
"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.