> ## Documentation Index
> Fetch the complete documentation index at: https://docs.serval.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Manage workflows with Terraform

> Keep workflow code in Git, review changes in pull requests, and deploy them with the Serval Terraform provider

The [Serval Terraform provider](https://registry.terraform.io/providers/ServalHQ/serval/latest) manages workflows as code. Each workflow's TypeScript lives in your repository, changes go through pull request review, and `terraform apply` publishes the reviewed version to Serval.

> [!TIP]
>
> To keep workflows in Git without Terraform, use the [Serval CLI](https://docs.serval.com/sections/cli/push-pull-configuration.md). It pulls a team's workflows and skills into files and pushes your edits back.

## What Terraform manages

The `serval_workflow` resource covers a workflow's code and core settings:

| Attribute | Required | Description |
| :- | :- | :- |
| `team_id` | Yes | UUID of the team that owns the workflow. Changing it replaces the workflow. |
| `name` | Yes | Display name. |
| `content` | Yes | The workflow's TypeScript code. |
| `description` | Yes | Description shown with the workflow. The provider marks it optional, so `terraform plan` won't catch a missing one, but Serval rejects a new workflow without it. |
| `is_published` | No | When `true`, each apply that changes the workflow publishes the new version. |
| `require_form_confirmation` | No | Whether users review their inputs before the workflow runs. See [Form confirmation](https://docs.serval.com/sections/documentation/workflows/Configure/inputs.md#form-confirmation). |
| `tag_ids` | No | IDs of the team tags to attach. |

`id` and `has_unpublished_changes` are read-only.

Set these in the app instead. Terraform updates leave them unchanged:

* Who can run the workflow
* Service Catalog listing and the Service Catalog description
* The access package whose connections the workflow uses. Workflows created by Terraform start in the team's default package.
* [Approvals](https://docs.serval.com/sections/documentation/workflows/Configure/approvals.md)

> [!NOTE]
>
> Leave out the provider's `execution_scope` attribute. Serval no longer reads it. Set who can run a workflow in the app.

## Before you begin

You need:

* Terraform 1.11 or later
* [Remote state storage](https://developer.hashicorp.com/terraform/language/backend) that everyone running Terraform shares, such as an S3 bucket or HCP Terraform
* Two API keys, both restricted to the teams Terraform manages:

  * An apply key with the `workflows:read`, `workflows:write`, and `workflows:deploy` permission bundles. `workflows:deploy` is only needed to publish. Without it, applies save drafts.
  * A plan key with only `workflows:read`, for running `terraform plan` on pull requests.

  Org Admins create keys in [Organization settings](https://docs.serval.com/sections/documentation/platform/org-settings.md#api-keys), and Team Managers create team-scoped keys in **Team Settings** → **API Keys**.
* The UUID of each team. Open **Team Settings** → **Your Team** and select **Copy team UUID**.

> [!WARNING]
>
> Don't use local state for a shared configuration. A run that starts from empty state tries to create every workflow again, and the apply fails because workflow names must be unique within a team.

## Set up the provider

**1. Declare the provider**

```hcl theme={null}
terraform {
  required_version = ">= 1.11"

  required_providers {
    serval = {
      source  = "ServalHQ/serval"
      version = "~> 0.26.0"
    }
  }

  backend "s3" {
    bucket       = "example-terraform-state"
    key          = "serval/workflows.tfstate"
    region       = "us-east-1"
    use_lockfile = true
  }
}

provider "serval" {}
```

Turn on state locking so two runs can't write state at once. `use_lockfile` does this for S3; other backends have their own setting.

Pin the minor version. The provider is pre-1.0, so a new minor release can include breaking changes. `~> 0.26.0` accepts only patch releases.

**2. Provide credentials**

The provider reads the API key from environment variables and exchanges it for an access token on each run:

```bash theme={null}
export SERVAL_CLIENT_ID="<client-id>"
export SERVAL_CLIENT_SECRET="<client-secret>"
```

If your organization is in the EU region, also set the API URL:

```bash theme={null}
export SERVAL_BASE_URL="https://public.eu1.serval.com"
```

[Data residency](https://docs.serval.com/sections/documentation/platform/data-residency.md) lists the API URL for each region. Keep credentials out of `.tf` files and store them as CI secrets.

## Define a workflow

Keep each workflow's code in its own file and load it with `file()`:

```text theme={null}
.
├── main.tf
└── workflows/
    └── onboard-employee/
        └── index.ts
```

```hcl theme={null}
locals {
  it_team_id = "<team-uuid>"
}

resource "serval_workflow" "onboard_employee" {
  team_id      = local.it_team_id
  name         = "Onboard employee"
  description  = "Creates accounts and assigns starter access for a new hire."
  content      = file("${path.module}/workflows/onboard-employee/index.ts")
  is_published = true
}
```

A team UUID isn't secret, so keep it in the configuration where CI runs can read it.

`file()` passes the code through unchanged. If you write code inline with `<<-EOT` instead, Terraform reads `${...}` and `%{...}` as its own syntax. Escape them as `$${...}` and `%%{...}` so TypeScript template literals reach Serval intact.

## Bring existing workflows under Terraform

Import workflows that already exist instead of recreating them. An import keeps the workflow's ID, version history, and run history.

**1. Pull the code and IDs**

Run [`serval pull`](https://docs.serval.com/sections/cli/push-pull-configuration) for the team. Each `workflow.yaml` it writes includes the workflow's `id`, and `index.ts` holds the code to copy into your repository.

**2. Add an import block next to each resource**

```hcl theme={null}
import {
  to = serval_workflow.onboard_employee
  id = "<workflow-id>"
}
```

**3. Plan and apply**

Run `terraform plan` and adjust the configuration until the plan shows only imports. Set `is_published` to match the workflow: `true` for a live workflow, `false` for one that has never been published. Importing an unpublished workflow with `is_published = true` publishes it on apply. Apply the plan, then delete the `import` blocks.

## Review changes in pull requests

A typical setup runs `terraform plan` on every pull request and `terraform apply` after merge, so changes reach Serval only after review.

A pull request can change the files a CI job runs, so give pull-request jobs only the read-only plan key. Keep the apply key in a GitHub [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment) whose deployment branches are limited to `main`. Jobs from other branches can't read its secrets. Anyone who can open a pull request can still use the plan key to read workflows on its teams, so limit it to the teams this repository manages.

This GitHub Actions example uses:

* Repository secrets `SERVAL_PLAN_CLIENT_ID` and `SERVAL_PLAN_CLIENT_SECRET` for the plan key
* An environment named `serval` with secrets `SERVAL_CLIENT_ID` and `SERVAL_CLIENT_SECRET` for the apply key

Both jobs also need credentials for your state storage.

```yaml theme={null}
name: serval-workflows

on:
  pull_request:
  push:
    branches: [main]

jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    env:
      SERVAL_CLIENT_ID: ${{ secrets.SERVAL_PLAN_CLIENT_ID }}
      SERVAL_CLIENT_SECRET: ${{ secrets.SERVAL_PLAN_CLIENT_SECRET }}
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -input=false
      - run: terraform plan -input=false -lock=false

  apply:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: serval
    concurrency:
      group: serval-apply
      cancel-in-progress: false
    env:
      SERVAL_CLIENT_ID: ${{ secrets.SERVAL_CLIENT_ID }}
      SERVAL_CLIENT_SECRET: ${{ secrets.SERVAL_CLIENT_SECRET }}
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init -input=false
      - run: terraform apply -input=false -auto-approve -lock-timeout=5m
```

The `serval-apply` concurrency group runs one apply at a time. Pull-request plans aren't in it, so they never cancel a waiting apply. Plans skip the state lock with `-lock=false`, and `-lock-timeout` lets an apply wait out any other lock holder instead of failing. Protect `main` so changes merge only after review.

## How applies behave

* **Publishing:** With `is_published = true`, each apply that changes the workflow publishes a new version, which appears in [version history](https://docs.serval.com/sections/documentation/workflows/Manage/versioning.md). With `is_published = false`, the change is saved as a draft. It doesn't unpublish a live workflow, and on a live workflow every plan shows `is_published` changing, so keep it `true` once a workflow is published.
* **Failed publishing:** If Serval rejects the code, the apply fails before anything changes. If publishing fails for another reason, such as a connection the workflow can't use, the new code can stay behind as a draft while the live version is unchanged.
* **Edits made in Serval:** Terraform compares your files with the workflow's latest draft. Changes made in the app or with Catalyst show up in the next plan, and an apply replaces them with the code in your repository. Make changes to Terraform-managed workflows in the repository.
* **Removing a resource:** Deleting a `serval_workflow` block, or running `terraform destroy`, deletes the workflow in Serval. You can restore it from **Team Settings** → **Recently deleted** for 30 days. It comes back as an unpublished draft, so import it again before managing it with Terraform.
* **Changing `team_id`:** Terraform deletes the workflow and creates a new one with a new ID. Anything that refers to the old ID stops working.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.