I wanted to put a SignalR hub behind Azure API Management (APIM) so I could keep the same “front door” for everything, and so I could apply a multi-region routing policy for load balancing and failover. My backend is an Azure Container App that hosts the SignalR hub, and my broader infrastructure is built around a regional-stamp pattern where each region has its own set of resources and APIM stitches them together.

The HTTP side of this setup is already working well for me. The WebSocket side is where things started to get confusing, mostly because WebSockets are long-lived connections and I’m also trying to keep the multi-region story clean.

Creating the WebSocket API and operation in APIM

My first attempt was to create a new API and then define an operation that should handle the connection upgrade. I set up an operation that matches the hub path, expecting APIM to return a 101 Switching Protocols response for the WebSocket upgrade.

resource "azurerm_api_management_api_operation" "realtime_ws_connect" {
  resource_group_name = var.api_management.resource_group
  api_management_name = var.api_management.name
  api_name            = azurerm_api_management_api.realtime_ws.name
  operation_id        = "ws-connect-hub1"

  display_name = "SignalR hub websocket connect"
  method       = "GET"
  url_template = "/hub1"

  response {
    status_code = 101
  }
}

The first hard failure: APIM rejects the service URL

When I tried to apply the configuration, it failed, and the Terraform error didn’t give me much to work with. I ended up having to jump into the Azure Portal Activity Log to see the real reason.

The error boiled down to: APIM rejected the API’s serviceUrl as invalid.

Here’s what the Activity Log message looked like (I’m keeping the meaning intact, but the key point is that APIM returned a BadRequest with a ValidationError on serviceUrl):

One or more fields contain incorrect values: ValidationError on serviceUrl, “Invalid value of the Web service URL”

Here is what the actual activity log message looks like:

{
  "properties": {
    "statusCode": "BadRequest",
    "serviceRequestId": null,
    "statusMessage": {
      "error": {
        "code": "ValidationError",
        "message": "One or more fields contain incorrect values:",
        "details": [
          {
            "code": "ValidationError",
            "target": "serviceUrl",
            "message": "Invalid value of the Web service URL"
          }
        ]
      }
    },
    "eventCategory": "Administrative",
    "entity": "/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.ApiManagement/service/<apim-name>/apis/<api-name>;rev=1",
    "message": "Microsoft.ApiManagement/service/apis/write",
    "hierarchy": "<tenant-id>/<subscription-id>"
  }
}

Once I traced it back, the problem wasn’t “WebSockets are hard” yet. It was simpler: I had accidentally hard-coded the protocol into the Container App endpoint output, and that propagated into APIM in a way that produced an invalid service_url for the WebSocket API.

The subtle root cause: protocol hard-coded too early

The problem here is that I inadvertently hard coded the protocol in the Azure Container App endpoint when I provisioned it in the regional-stamp component.

The regional-stamp component provisions the Container App and outputs this value:

locals {
  backend_url = "https://${azurerm_container_app.main[0].ingress[0].fqdn}"
}
output "endpoint" {
  value = local.backend_url
}

Then the regional-stamp component passes this endpoint into the api component which ties together all the regional backends into one Azure API Management API.

component "api" {
  source = "./src/terraform/api"

  inputs = {
    application_name = var.application_name
    environment_name = var.environment_name
    tags             = var.tags
    api_management   = component.dependencies.api_management
    endpoint         = component.stamp-primary.endpoint
    backends = [
      component.stamp-primary.backend_name
    ]
  }

  providers = {
    azurerm = provider.azurerm.this
    http    = provider.http.this
  }
}

If I hard code the https:// into the endpoint it limits the api component to only supporting that protocol when setting up azurerm_api_management_api resources.

How the HTTP API was working (and why it hid the issue)

My HTTP API pulls the OpenAPI document from the backend and imports it. This is what my HTTP API looks like:

data "http" "openapi" {
  url = "https://${var.endpoint}/openapi/v1.json"
}

resource "azurerm_api_management_api" "main" {
  name                = "realtime"
  resource_group_name = var.api_management.resource_group
  api_management_name = var.api_management.name

  display_name          = "realtime"
  protocols             = ["https"]
  subscription_required = false
  revision              = "1"
  path                  = "realtime"

  import {
    content_format = "openapi+json"
    content_value  = data.http.openapi.response_body
  }
}

Because the HTTP stack already assumed https://, hard-coding it in the stamp output didn’t hurt. It just meant my “endpoint” variable wasn’t truly an endpoint hostname anymore; it was a full URI prefix. That distinction only became painful once I introduced a second protocol.

Defining the WebSocket API and fixing the endpoint output

Here’s the WebSocket API definition I was trying to use:

resource "azurerm_api_management_api" "realtime_ws" {
  name                = "realtime-hub"
  resource_group_name = var.api_management.resource_group
  api_management_name = var.api_management.name

  display_name          = "realtime-hub"
  protocols             = ["wss"]
  subscription_required = false
  revision              = "1"
  path                  = "realtime" # same base as your http api path
  api_type              = "websocket"
  service_url           = "wss://${var.endpoint}"
  description           = "WebSocket entrypoint for SignalR hub connections via APIM"
}

The fix was to refactor the regional-stamp output so it returns a neutral host value instead of a protocol-prefixed URL, something like:

ca-o8l7l7dkd6.graysea-006ff647.westus3.azurecontainerapps.io

This allows me to embed the full URI with the correct protocol prefix within the api component — probably where it belongs.

That lets the API component decide how to construct the right URLs per API type. For HTTP it can use https://..., and for WebSockets it can use wss://..., without accidentally double-prefixing anything.

What APIM generated, and what still feels “missing”

After the WebSocket API was created successfully, APIM seemed to automatically generate an endpoint called onHandshake. It used the wss:// version of the Container App endpoint I provided.

That automatic handshake behavior makes sense at a high level, but it also makes the configuration feel different from my HTTP setup. With HTTP, I can see all the operations imported from OpenAPI and I can see policies applied consistently: set backend, forward request, and so on. With the WebSocket API, the shape is different, and it isn’t obvious yet where my hub path (/hub1) truly “lives” in the APIM routing model.

This is where my concern comes in: I don’t want to end up with an APIM endpoint that upgrades a socket correctly but doesn’t actually map to the hub route I expect, or that bypasses the policy logic I rely on for regional selection.

Where I think the work continues

At this point, the infrastructure part is mostly unblocked: I can create the WebSocket API in APIM, and I can supply a valid service_url. The next step is making sure the client can actually connect through APIM end-to-end, and that the connection is routed to the right Container App backend.

Conceptually, I’m trying to line up three things:

First, the client needs a stable URL to connect to through APIM, the same way it does for HTTP.

Second, APIM needs to forward that WebSocket traffic to the correct regional backend, using the same regional routing and failover rules I already use for HTTP.

Third, I need to account for the fact that SignalR involves a long-lived connection, which makes session affinity feel more important than it does for stateless HTTP calls. If a client is connected via WebSocket, I don’t want APIM “load balancing” to become “randomly bounce this client across regions” mid-connection, and I also want to understand what failure modes look like if a region goes down and a reconnect occurs.

Conclusion

The immediate blocker I hit wasn’t a deep SignalR problem — it was an endpoint modeling mistake. By hard-coding https:// into the regional-stamp output, I accidentally made my “endpoint” variable protocol-specific, and that turned into an invalid service_url when I tried to compose a wss:// URL in APIM. Refactoring the stamp output to return just the host (no scheme) fixed the APIM validation issue and allowed the WebSocket API to be created successfully.

Now the work shifts from “can I provision the API?” to “can I route the connection correctly?” The pieces I’m still working through are how APIM maps WebSocket APIs and operations (including the auto-generated onHandshake behavior), where the hub path should be expressed so it’s unambiguous, and how to apply regional backend selection in a way that respects the reality of long-lived SignalR connections.