# PR Slot Deployments

## Overview

PR slot deployments let you preview changes from a pull request in a live environment without affecting other users. When a PR pipeline runs with slot deployment enabled, it deploys your code to an Azure App Service [deployment slot](https://learn.microsoft.com/en-us/azure/app-service/deploy-staging-slots). You activate routing to that slot via a cookie — only your browser sees the PR code while everyone else continues hitting the production slot.

Automated tests and CI pipelines can target a specific slot directly by sending the `x-saif-slot-routing` request header (e.g. `x-saif-slot-routing: my-project-id=pr-1234`), without needing a browser or cookie.

[TOC]

!!! tip "Quick navigation"
    **New project** created with `saif new`? Start at [Step 1 — Enable on the main pipeline](#step-1-enable-enableslotdeployment-on-the-main-pipeline).  
    **Existing project** adding slot deployments? Jump to [Enabling on an Existing Project](#enabling-on-an-existing-project).

## Prerequisites

- **Pipeline templates v3** — your pipeline must reference `azure-dotnet-api-pr-v3.yml` or `azure-react-web-pr-v3.yml`
- **`saif-apiservice` module** version `>= 3.8.0`

## Enabling PR Slot Deployments

### Step 1 — Enable `enableSlotDeployment` on the main pipeline

The main (production) pipeline must opt in first. This triggers the `setup_pr_slot_cleanup` deployment which creates the service connection and service hook needed for automatic slot cleanup.

!!! important
    Your Azure infrastructure (App Service, resource group, APIM API) must already exist before slots can be created. If this is a new project, run the main pipeline **at least once without** `enableSlotDeployment` to deploy the base infrastructure, then add the parameter and run again.

In your **main pipeline** (`azure-pipelines-api.yml` / `azure-pipelines-web.yml`):

```yaml
extends:
  template: azure-dotnet-api-v3.yml@templates
  parameters:
    applicationName: ${{ variables.ApplicationName }}
    projectId: ${{ variables.ProjectId }}
    enableSlotDeployment: true
    # ... other parameters
```

Commit this to `main` and run the pipeline. The `setup_pr_slot_cleanup` deployment stage will create:

1. An **incoming webhook service connection** (`PRClosedWebhook-{projectId}`)
2. A **service hook subscription** that fires on `git.pullrequest.updated` with `StatusUpdateNotification`

These resources are created idempotently — re-running the pipeline is safe.

### Step 2 — Create the slot cleanup pipeline

Add an `azure-pipelines-pr-slot-cleanup.yml` file to your `.azdo/` folder:

```yaml
variables:
  - template: vars/base.yml
  - template: saif-vars.yml@templates
  - group: TerraformCloud

trigger: none

pool:
  vmImage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3
  webhooks:
    - webhook: PRClosed-{YourProjectId}
      connection: PRClosedWebhook-{YourProjectId}

stages:
  - template: azure-pr-slot-cleanup.yml@templates
    parameters:
      projectId: ${{ variables.ProjectId }}
      applicationName: ${{ variables.ApplicationName }}
      prNumber: ${{ parameters['PRClosed-{YourProjectId}'].resource.pullRequestId }}
      prStatus: ${{ parameters['PRClosed-{YourProjectId}'].resource.status }}
```

Replace `{YourProjectId}` with your actual project ID (e.g. `saif-my-service`).

If your project has a frontend, also include the `frontEndProjectId` parameter:

```yaml
      frontEndProjectId: ${{ variables.FrontEndProjectId }}
```

Then create a pipeline in Azure DevOps pointing to this file. The webhook trigger name must match the service connection created in Step 1.

!!! tip "New projects from `saif new`"
    The `saif-feature-api` and `saif-feature-web-standalone` templates already include the cleanup pipeline file. You only need to create this manually for existing projects.

### Step 3 — Enable `enableSlotDeployment` on the PR pipeline

In your **PR pipeline** (`azure-pipelines-api-pr.yml` / `azure-pipelines-web-pr.yml`):

```yaml
extends:
  template: azure-dotnet-api-pr-v3.yml@templates
  parameters:
    applicationName: ${{ variables.ApplicationName }}
    projectId: ${{ variables.ProjectId }}
    enableSlotDeployment: true
    # ... other parameters
```

This can be pushed to the branch that will create a PR (enables for that PR only) or to `main` (enables for all future PRs).

If your project has a separate web (frontend) pipeline, enable `enableSlotDeployment: true` there as well.

### Step 4 — Develop and create a PR

Develop using the standard workflow. When ready, create a pull request and allow the API and/or web validation stages to complete.

### Step 5 — Activate the slot

!!! note
    Slot activation is only available in the `test` environment. It is intentionally not available in QA, UAT, or production.

When deployment completes, a comment is posted on your PR:

> ### 🔀 PR Slot Deployment — test
>
> A backend deployment slot `pr-1234` has been created for this PR.
>
> | Action | Link |
> |---|---|
> | **Activate slot routing** | 🟢 Activate |
> | **Deactivate slot routing** | 🔴 Deactivate |
>
> **Slot routing value:** `my-project-id=pr-1234`
>
> Clicking **Activate** sets a cookie that routes your requests to the PR slot.
> Other users and environments are not affected.

If frontend slot deployment is also enabled, a second comment will appear for the web component.

Click **Activate** — this opens a confirmation page and sets the `x-saif-slot-routing` cookie in your browser. APIM matches your project ID in the cookie and routes subsequent requests to the PR slot.

For web/frontend activation, an additional `x-ms-routing-name` cookie is set scoped to your app's path. This is because frontend requests go directly from Front Door to App Service (bypassing APIM), so Azure's built-in ARR routing is used instead.

### Step 6 — Deactivate when done

Click **Deactivate** when validation is complete. This removes only the designated project ID from the cookie value — other activated project IDs remain intact. Your requests resume going to the production slot.

!!! tip
    If you don't deactivate, the cookie value persists for that project ID. The alternative is to clear the entire `x-saif-slot-routing` cookie in your browser, but that deactivates all project IDs at once.

### Step 7 — Slot cleanup

When the PR is completed or abandoned, the slot cleanup pipeline is triggered automatically via the service hook to remove the deployment slot associated with that PR. The cleanup pipeline:

1. Receives the webhook payload containing the PR number and status
2. Deletes the `pr-{number}` App Service slot
3. Removes the traffic routing rule for that slot

## Enabling on an Existing Project

For projects not created from the latest `saif new` templates, follow this checklist:

### 1. Update `saif-apiservice` module version

In your `infra/api/main.tf` (or `infra/web/main.tf` for frontend-only):

```hcl
module "webapp" {
  source  = "app.terraform.io/saif-corp/saif-apiservice/azurerm"
  version = "~> 3.8.0"
  # ...
}
```

Run the main pipeline to deploy the updated APIM policy fragments.

### 2. Add `enableSlotDeployment: true` to the main pipeline

Edit `.azdo/azure-pipelines-api.yml`:

```yaml
extends:
  template: azure-dotnet-api-v3.yml@templates
  parameters:
    applicationName: ${{ variables.ApplicationName }}
    projectId: ${{ variables.ProjectId }}
    enableSlotDeployment: true
    # ... existing parameters
```

Push to `main` and run the pipeline. This creates the service connection and service hook.

### 3. Create the cleanup pipeline file

Add `.azdo/azure-pipelines-pr-slot-cleanup.yml`:

```yaml
variables:
  - template: vars/base.yml
  - template: saif-vars.yml@templates
  - group: TerraformCloud

trigger: none

pool:
  vmImage: 'ubuntu-latest'

resources:
  repositories:
    - repository: templates
      type: git
      name: SAIF/pipeline-templates
      ref: refs/heads/releases/v3
  webhooks:
    - webhook: PRClosed-{YourProjectId}
      connection: PRClosedWebhook-{YourProjectId}

stages:
  - template: azure-pr-slot-cleanup.yml@templates
    parameters:
      projectId: ${{ variables.ProjectId }}
      applicationName: ${{ variables.ApplicationName }}
      prNumber: ${{ parameters['PRClosed-{YourProjectId}'].resource.pullRequestId }}
      prStatus: ${{ parameters['PRClosed-{YourProjectId}'].resource.status }}
```

Replace `{YourProjectId}` with your project ID. If you have a frontend, add:

```yaml
      frontEndProjectId: ${{ variables.FrontEndProjectId }}
```

### 4. Create the ADO pipeline

In Azure DevOps, create a new pipeline:

1. Go to **Pipelines** → **New Pipeline** → **Azure Repos Git**
2. Select your repository
3. Choose **Existing Azure Pipelines YAML file**
4. Set path to `/.azdo/azure-pipelines-pr-slot-cleanup.yml`
5. Name it `{YourProjectId} - PR Slot Cleanup`
6. Save (do not run manually — it is webhook-triggered)

!!! note
    On the first webhook-triggered run, ADO will prompt for pipeline authorization to the service connection. Approve it once and subsequent runs will proceed automatically.

### 5. Enable `enableSlotDeployment` on the PR pipeline

Edit `.azdo/azure-pipelines-api-pr.yml`:

```yaml
extends:
  template: azure-dotnet-api-pr-v3.yml@templates
  parameters:
    applicationName: ${{ variables.ApplicationName }}
    projectId: ${{ variables.ProjectId }}
    enableSlotDeployment: true
    # ... existing parameters
```

### 6. Verify

1. Create a test PR with a trivial change
2. Confirm the PR pipeline creates a slot and posts the activation comment
3. Click **Activate** and verify routing works
4. Complete the PR and confirm the cleanup pipeline triggers and deletes the slot

## Multi-Service Chains

Slot routing propagates across service-to-service calls automatically. You do not have to deploy the entire chain to PR slots — any combination works:

- **Experience only** — useful when only the frontend-facing layer has changes; downstream services run on production
- **Process or System only** — the upstream caller (running on production) propagates the routing header automatically to the PR slot
- **Process → System** — both inner layers on PR slots; the experience layer runs on production
- **Experience + Process** or any other subset

When the entry point is a browser request through an experience API, the `x-saif-slot-routing` cookie set by **Activate** is enough — the Forge middleware propagates it downstream as a header automatically. When you are testing deeper layers without a frontend (e.g. hitting a process API directly, or running automated tests), send the header explicitly:

```http
GET /api/my-endpoint
x-saif-slot-routing: my-proc-project-id=pr-1234
```

This works because Forge's `SlotRoutingMiddleware` reads `x-saif-slot-routing` from the inbound Cookie (browser) **or** header (service-to-service or direct test), and `SlotRoutingDelegatingHandler` attaches it as a header on all outgoing `HttpClient` requests.

Slot routing is registered automatically when you call `AddJwtBearerServices()` or `AddOpenIdConnectServices()`. If your service does not use either (e.g. a purely internal API that authenticates via a different mechanism), register it explicitly:

```csharp
// Program.cs
builder.Services.AddSlotRouting();

// ...

app.UseSlotRouting();
```

This registers `SlotRoutingMiddleware` and `SlotRoutingDelegatingHandler` via `ConfigureHttpClientDefaults`, so all `HttpClient` instances propagate the routing header automatically.

```mermaid
sequenceDiagram
    participant Browser
    participant APIM as APIM Gateway
    participant Exp as Exp API (PR slot)
    participant Proc as Proc API (PR slot)

    Browser->>APIM: Request with cookie<br/>x-saif-slot-routing=exp=pr-100,proc=pr-200
    APIM->>Exp: Route to exp PR slot
    Note over Exp: SlotRoutingMiddleware reads value<br/>DelegatingHandler propagates on outbound call
    Exp->>APIM: Outbound call with header<br/>x-saif-slot-routing=exp=pr-100,proc=pr-200
    APIM->>Proc: Route to proc PR slot
```

!!! note
    The middleware reads from the cookie (browser requests) or header (service-to-service). APIM's `inbound-set-slot-backend` fragment supports both — cookie for the first hop from the browser, header for subsequent hops between services.

!!! tip "Staging slots also benefit"
    Because `inbound-set-slot-backend` runs on every API in the environment, the blue-green **staging** slot is also routable via cookie — set `x-saif-slot-routing` to `{project-id}=staging` to direct your browser to the staging slot during a blue-green validation window. This works without any extra configuration.

## Troubleshooting

### Slot shows production code after activation

- **Check cookie**: Open DevTools → Application → Cookies and verify `x-saif-slot-routing` is present with the correct project ID and slot name
- **Check module version**: Ensure your `saif-apiservice` Terraform module is on `>= 3.8.0` — older versions don't include the `inbound-set-slot-backend` policy fragment
- **Check environment**: Slot activation only works in `test`
- **Run infrastructure pipeline**: The APIM policy fragment is deployed by Terraform on `main` — if this is the first time enabling slots, ensure the pipeline has run after the module version update

### Downstream API hits production despite upstream being on a slot

- Verify the downstream API also has module version `>= 3.8.0` so the `inbound-set-slot-backend` fragment is included in its APIM policy
- Confirm your service calls `AddJwtBearerServices()`, `AddOpenIdConnectServices()`, or the standalone `AddSlotRouting()` — one of these must be present for the `SlotRoutingDelegatingHandler` to be registered
- If using a custom `HttpClient` setup that bypasses `ConfigureHttpClientDefaults`, manually add `SlotRoutingDelegatingHandler` to that client's pipeline

### 403 on downstream API calls

- Verify your authentication configuration is set up correctly between the calling and downstream APIs
- Check that the downstream API's `infra/auth/corp/config.yml` grants the appropriate scopes and roles for the calling application
- Confirm the downstream API has an `authorized_apps` entry for the calling API's project ID

### Cookie not being set

- The activation endpoint returns 404 in environments where slot deployment is not enabled (QA, UAT, production)
- Verify you're hitting the correct Front Door hostname for your environment

## Limitations

### Concurrent open PRs

Azure App Service limits deployment slots per app based on the plan tier. The production slot occupies one, leaving a maximum of **19 simultaneously open PR slots** per app on Premium (v1–v4) and Isolated plans. Each app has its own independent quota — an API and its web front end each get 19, not 19 combined. See [Azure App Service limits](https://learn.microsoft.com/en-us/azure/azure-resource-manager/management/azure-subscription-service-limits#azure-app-service-limits) for the full tier breakdown.

