Skip to content

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. 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.

Quick navigation

New project created with saif new? Start at Step 1 — Enable on the main pipeline.
Existing project adding slot deployments? Jump to 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):

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:

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:

      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.

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):

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):

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:

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:

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:

      frontEndProjectId: ${{ variables.FrontEndProjectId }}

4. Create the ADO pipeline

In Azure DevOps, create a new pipeline:

  1. Go to PipelinesNew PipelineAzure 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:

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:

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:

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

// ...

app.UseSlotRouting();

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

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.

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
  • 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 for the full tier breakdown.