This guide reproduces the architecture below using two Linux VMs in an Azure VNet to stand in for on-prem API servers. Once the pattern works with the VMs, the same Function App can target real on-prem endpoints (reachable over ExpressRoute / VPN) with no code changes — only the BACKEND_*_BASE_URL app settings change.
Fabric Notebook ──(Managed Private Endpoint)──▶ Function App ──(VNet integration)──▶ VM1 (FastAPI, Basic auth)
|
└──▶ VM2 (FastAPI, Basic auth)
Components:
- Backend VMs: two Ubuntu VMs in
vnet-func-demo / snet-backend, each running FastAPI on port 8000 with HTTP Basic auth. - Function App: Python Flex Consumption plan, VNet-integrated to
snet-function-integration. Acts as a generic reverse proxy that dispatches by/<backend>/...URL prefix and rewrites response URLs so callers never see private IPs. - Fabric workspace: a notebook calls the Function App through a Managed Private Endpoint (MPE).
- Azure subscription (Owner / Contributor on the target RG).
- Local tooling (we used WSL Ubuntu):
azCLI (Azure CLI) ≥ 2.60func(Azure Functions Core Tools v4)- Python 3.12
curl,jq
- A Microsoft Fabric workspace assigned to a capacity that supports managed private endpoints (F64+ / Trial works).
- Conventions used below (PowerShell). Set these once per shell session; every later block reuses them:
$RG = "rg-fabric-mpe-demo" $LOC = "francecentral" $VNET = "vnet-func-demo" $SUBNET_BACKEND = "snet-backend" # 10.0.1.0/24 $SUBNET_FUNC = "snet-function-integration" # 10.0.2.0/24 $VM1_NAME = "vm-fabric-function-demo-1" $VM2_NAME = "vm-fabric-function-demo-2" $VM_NSG = "nsg-vm-backend" $UNIQUE = "<unique>" # e.g. your initials + 4 digits $FUNC = "func-fabric-mpe-demo" $STG = "stfabricmpedemo$UNIQUE" $BACKEND_USER = "demo_user" $BACKEND_PASS = '<Your strong password>' # single quotes prevent $-expansion $SUB = "<your-subscription-id-or-name>" $VM1_PUBLIC_IP = "<vm1-public-ip>" # fill in after §2.1 $VM2_PUBLIC_IP = "<vm2-public-ip>" # fill in after §2.1 $VM1_PRIVATE_IP = "10.0.1.4" # fill in after §2.1 $VM2_PRIVATE_IP = "10.0.1.5" # fill in after §2.1
In PowerShell, line continuation is a backtick
`at end of line (not a backslash). Variables are referenced with$Name; use"$Name/32"or$($Name)when concatenating with text.
az group create -n $RG -l $LOC
az network vnet create -g $RG -n $VNET `
--address-prefixes 10.0.0.0/16az network vnet subnet create -g $RG --vnet-name $VNET `
-n $SUBNET_BACKEND --address-prefixes 10.0.1.0/24Must be delegated to Microsoft.App/environments (Flex Consumption plan).
az network vnet subnet create -g $RG --vnet-name $VNET `
-n $SUBNET_FUNC --address-prefixes 10.0.2.0/24
az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET_FUNC `
--delegations Microsoft.App/environmentsaz network nsg create -g $RG -n $VM_NSG
az vm create -g $RG -n $VM1_NAME `
--image Ubuntu2204 `
--size Standard_B2s `
--admin-username azureuser `
--generate-ssh-keys `
--vnet-name $VNET --subnet $SUBNET_BACKEND `
--nsg $VM_NSG `
--public-ip-sku Standard
az vm create -g $RG -n $VM2_NAME `
--image Ubuntu2204 `
--size Standard_B2s `
--admin-username azureuser `
--generate-ssh-keys `
--vnet-name $VNET --subnet $SUBNET_BACKEND `
--nsg "" `
--public-ip-sku StandardBoth VMs sit in
snet-backend, so the single NSG already covers them. The--nsg ""on VM2 avoids creating a redundant per-NIC NSG.
Allow SSH from your laptop, and (temporarily) port 8000 so you can test the FastAPI directly from your laptop.
$MYIP = Invoke-RestMethod -Uri "https://api.ipify.org"
az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-ssh-from-me `
--priority 200 --access Allow --protocol Tcp --direction Inbound `
--source-address-prefixes "$MYIP/32" --destination-port-ranges 22
az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-fastapi-from-me `
--priority 210 --access Allow --protocol Tcp --direction Inbound `
--source-address-prefixes "$MYIP/32" --destination-port-ranges 8000Once the Function App is in place we will add a rule for the function subnet and remove the public-IP rule (see §6).
Repeat for both VMs. SSH in and set up a virtual environment:
ssh azureuser@<vm-public-ip>
sudo apt-get update && sudo apt-get install -y python3-venv
mkdir -p ~/app && cd ~/app
python3 -m venv venv
source venv/bin/activate
pip install fastapi uvicorn[standard]from fastapi import FastAPI, Depends, HTTPException, status, Request
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from datetime import datetime, timezone
import socket
import secrets
app = FastAPI(title="Fabric MPE Demo API")
security = HTTPBasic()
VALID_USERNAME = "demo_user"
VALID_PASSWORD = "<Your strong password>"
def authenticate(credentials: HTTPBasicCredentials = Depends(security)):
correct_username = secrets.compare_digest(credentials.username, VALID_USERNAME)
correct_password = secrets.compare_digest(credentials.password, VALID_PASSWORD)
if not (correct_username and correct_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
def get_vm_info():
hostname = socket.gethostname()
internal_ip = socket.gethostbyname(hostname)
return hostname, internal_ip
@app.get("/healthcheck")
def healthcheck(username: str = Depends(authenticate)):
hostname, internal_ip = get_vm_info()
return {
"status": "healthy",
"vm_name": hostname,
"internal_ip": internal_ip,
"timestamp": datetime.now(timezone.utc).isoformat(),
"message": f"Hello from {hostname}! Fabric MPE demo is running.",
}
@app.get("/data")
def data(username: str = Depends(authenticate)):
hostname, internal_ip = get_vm_info()
return {
"vm_name": hostname,
"internal_ip": internal_ip,
"records": [
{"id": 1, "product": "Historian", "region": "EMEA", "value": 42100},
{"id": 2, "product": "Wonderware", "region": "APAC", "value": 37850},
{"id": 3, "product": "InTouch", "region": "AMER", "value": 51200},
{"id": 4, "product": "System Platform", "region": "EMEA", "value": 29400},
],
}
@app.get("/links")
def links(request: Request, username: str = Depends(authenticate)):
base = str(request.base_url).rstrip("/") # e.g. http://10.0.1.4:8000
return {
"self": f"{base}/links",
"healthcheck_url": f"{base}/healthcheck",
"data_url": f"{base}/data",
}/etc/systemd/system/fastapi-demo.service:
[Unit]
Description=Fabric MPE Demo FastAPI Application
After=network.target
[Service]
User=azureuser
WorkingDirectory=/home/azureuser/app
ExecStart=/home/azureuser/app/venv/bin/uvicorn main:app --host 0.0.0.0 --port 8000
Restart=always
[Install]
WantedBy=multi-user.targetEnable + start:
sudo systemctl daemon-reload
sudo systemctl enable --now fastapi-demo
sudo systemctl status fastapi-democurl.exe -u "$($BACKEND_USER):$($BACKEND_PASS)" "http://$($VM1_PUBLIC_IP):8000/healthcheck"
curl.exe -u "$($BACKEND_USER):$($BACKEND_PASS)" "http://$($VM2_PUBLIC_IP):8000/healthcheck"Use
curl.exeexplicitly — barecurlin PowerShell is an alias forInvoke-WebRequest, which has different flags.
This repo is organized as:
src/
function_app.py
host.json
local.settings.json # gitignored
requirements.txt
notebooks/
fabric_call_function.ipynb
docs/
high-level-architecture.png
README.md
Teaser.md
LICENSE
The Azure Functions runtime expects function_app.py, host.json, and requirements.txt at the deployment root. Because they live under src/ here, the deployment commands below run from inside src/, and the Azure Pipelines build (see §3.6.5) is configured with src as its working directory.
azure-functions
fastapi
httpx
{
"version": "2.0",
"extensions": {
"http": {
"routePrefix": ""
}
}
}The empty routePrefix means the Function exposes routes at /<path> instead of /api/<path> — easier to reason about as a transparent proxy.
{
"IsEncrypted": false,
"Values": {
"AzureWebJobsStorage": "UseDevelopmentStorage=true",
"FUNCTIONS_WORKER_RUNTIME": "python",
"AzureWebJobsFeatureFlags": "EnableWorkerIndexing",
"BACKEND_VM1_BASE_URL": "http://<vm1-public-ip>:8000",
"BACKEND_VM2_BASE_URL": "http://<vm2-public-ip>:8000",
"BACKEND_USERNAME": "demo_user",
"BACKEND_PASSWORD": "<Your strong password>"
}
}Notes:
AzureWebJobsStorage="UseDevelopmentStorage=true"points at Azurite for local dev. Without it, the host reports the storage health check unhealthy (HTTP triggers still work, but the noise is annoying).- For local testing we use the VMs' public IPs. After deploying to Azure we switch to the private IPs.
- All backends share the same Basic-auth credentials in this demo. If each VM needs different credentials, use
BACKEND_VM1_USERNAME/BACKEND_VM1_PASSWORDetc. and adjust the code accordingly.
import os
import azure.functions as func
import fastapi
import httpx
# Map of backend tag -> base URL. Add more entries (and matching app settings)
# to expose additional VMs through the same proxy.
BACKENDS: dict[str, str] = {
"vm1": os.environ["BACKEND_VM1_BASE_URL"].rstrip("/"),
"vm2": os.environ["BACKEND_VM2_BASE_URL"].rstrip("/"),
}
BACKEND_AUTH = httpx.BasicAuth(
os.environ["BACKEND_USERNAME"],
os.environ["BACKEND_PASSWORD"],
)
_HOP_BY_HOP = {
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
"te", "trailers", "transfer-encoding", "upgrade",
"host", "content-length", "content-encoding",
}
fastapi_app = fastapi.FastAPI()
http_client = httpx.AsyncClient(timeout=30.0, auth=BACKEND_AUTH)
def _public_base_url(request: fastapi.Request) -> str:
proto = request.headers.get("x-forwarded-proto", request.url.scheme)
host = request.headers.get("x-forwarded-host") or request.headers.get("host")
return f"{proto}://{host}".rstrip("/")
def _rewrite_body(body: bytes, backend_base: str, public_base: str) -> bytes:
if not body:
return body
return body.replace(backend_base.encode(), public_base.encode())
@fastapi_app.api_route(
"/{backend}/{full_path:path}",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
async def proxy(backend: str, full_path: str, request: fastapi.Request):
backend_base = BACKENDS.get(backend)
if backend_base is None:
return fastapi.responses.JSONResponse(
status_code=404,
content={
"error": f"unknown backend '{backend}'",
"valid_backends": sorted(BACKENDS.keys()),
},
)
url = f"{backend_base}/{full_path}"
fwd_headers = {k: v for k, v in request.headers.items() if k.lower() not in _HOP_BY_HOP}
body = await request.body()
try:
upstream = await http_client.request(
method=request.method,
url=url,
params=request.query_params,
headers=fwd_headers,
content=body,
)
except httpx.RequestError as e:
return fastapi.responses.JSONResponse(
status_code=502,
content={
"status": "unreachable",
"error_type": type(e).__name__,
"error": str(e) or repr(e),
"target": url,
},
)
public_base = f"{_public_base_url(request)}/{backend}"
rewritten = _rewrite_body(upstream.content, backend_base, public_base)
resp_headers = {k: v for k, v in upstream.headers.items() if k.lower() not in _HOP_BY_HOP}
return fastapi.responses.Response(
content=rewritten,
status_code=upstream.status_code,
headers=resp_headers,
media_type=upstream.headers.get("content-type"),
)
app = func.AsgiFunctionApp(app=fastapi_app, http_auth_level=func.AuthLevel.ANONYMOUS)Key properties:
- Backend dispatch: the first URL segment selects the backend.
/vm1/healthcheck→ VM1,/vm2/data→ VM2. Unknown tags get a404listing the valid options. - Catch-all sub-route
/{full_path:path}accepts any method below the backend tag — adding new endpoints to a backend later requires zero changes here. - Header forwarding preserves
Authorization, custom headers, content-type. Hop-by-hop headers are stripped (RFC 7230 §6.1). - URL rewriting: every occurrence of the selected backend's base URL in the response body is replaced with
https://<func>/<backend>, so links the VM returns stay self-consistent and routable through this proxy. Callers never see the private IP.
cd <repo>/src
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
func startTest:
curl http://localhost:7071/vm1/healthcheck
curl http://localhost:7071/vm2/healthcheck
curl http://localhost:7071/vm1/dataInstead of running func azure functionapp publish from your laptop, put the project in Azure Repos and let the Function App's Deployment Center pull from it on every commit to main. This is the recommended path for ongoing maintenance.
- An Azure DevOps organization + project (e.g.
https://dev.azure.com/<org>/FabricIntegration). - You signed in to Azure DevOps with the same Entra ID identity that owns the Azure subscription — otherwise Deployment Center won't see your org in the dropdown.
- The Function App from §4 already created (Deployment Center lives on the Function App resource).
Add two ignore files so secrets and local-only junk never leave your machine.
.gitignore:
# Python
__pycache__/
*.py[cod]
venv/
.venv/
.python_packages/
# Azure Functions
bin/
obj/
.azurefunctions/
local.settings.json
# Editors / OS
.vscode/
.idea/
.DS_Store.funcignore (controls what gets packaged for deployment):
.git/
.gitignore
.venv/
venv/
__pycache__/
local.settings.json
*.md
local.settings.jsonholds the backend password. Never commit it. The Function App's App Settings (set in §4.3) are the source of truth in Azure.
In the Azure DevOps portal:
- Open your project → Repos.
- Use the repo selector at the top → New repository.
- Name it
fastapi-func, leave Add a README unchecked, click Create.
Or via CLI:
$ORG = "<your-org>"
$PROJECT = "FabricIntegration"
$REPO = "fastapi-func"
az extension add --name azure-devops
az devops configure --defaults organization="https://dev.azure.com/$ORG" project=$PROJECT
az repos create --name $REPO --query webUrl -o tsvcd <repo> # repository root (one level above src/)
git init -b main
git add .
git status # verify no local.settings.json, no venv/
git -c user.email="you@example.com" `
-c user.name="Your Name" `
commit -m "Initial commit: Fabric MPE function proxy"
git remote add origin "https://dev.azure.com/$ORG/$PROJECT/_git/$REPO"
git push -u origin mainFor authentication, the easiest path is Git Credential Manager (a browser window opens on first push for Entra ID sign-in). Alternatively, generate a Personal Access Token (User settings → Personal access tokens → scope Code: Read & write) and paste it as the password.
Refresh the repo in the Azure DevOps portal — src/function_app.py, src/host.json, src/requirements.txt, .gitignore, src/.funcignore are present, and src/local.settings.json is not.
In the Azure portal:
- Open the Function App (
func-fabric-mpe-demo-<unique>). - Left blade → Deployment → Deployment Center.
- Source dropdown → Azure Repos.
- Fill in:
- Organization: your Azure DevOps org
- Project:
FabricIntegration - Repository:
fastapi-func - Branch:
main
- Build provider: Azure Pipelines (continuous integration).
- Azure subscription / Resource group / App: prefilled with this Function App.
- Click Save at the top.
What this does behind the scenes:
- Creates a service connection in your Azure DevOps project pointing to the subscription.
- Commits an
azure-pipelines.ymlfile to the root of the repo onmain. - Triggers the first pipeline run, which builds the Python project and zip-deploys it to the Function App.
Working directory adjustment for this layout. The auto-generated
azure-pipelines.ymlassumes the function project is at the repo root. Because we keep it undersrc/, edit the generated YAML and setworkingDirectory: srcon theAzureFunctionApp@1task (and on thepip install/ archive steps if they reference paths). A minimal patch looks like:- task: AzureFunctionApp@1 inputs: azureSubscription: '<service-connection>' appType: functionAppLinux appName: '$(functionAppName)' package: '$(System.DefaultWorkingDirectory)/src' runtimeStack: 'PYTHON|3.12'Commit the change to
mainand the next pipeline run picks it up.
The portal banner warning ("You are now in the production slot, which is not recommended for setting up CI/CD") is informational. For this demo, deploying directly to production is fine. For real workloads, create a
stagingdeployment slot and point Deployment Center at the slot, then swap.
- Azure DevOps → Pipelines → watch the run finish green.
- Function App → Deployment Center → Logs tab: shows the deployment with commit SHA + status Success.
- Smoke-test:
$HOSTNAME = az functionapp show -g $RG -n $FUNC --query defaultHostName -o tsv curl.exe "https://$HOSTNAME/vm1/healthcheck" curl.exe "https://$HOSTNAME/vm2/healthcheck"
$BRANCH = "feature/<short-name>"
git checkout -b $BRANCH
# edit files...
git add -p
git commit -m "Describe the change"
git push -u origin $BRANCHOpen a PR in Azure DevOps → review → merge to main. The merge commit triggers Deployment Center, which redeploys the Function App automatically. No more local func ... publish.
Deployment Center → Disconnect removes the integration but leaves the azure-pipelines.yml and service connection in place. Delete those manually in Azure DevOps if you want a fully clean slate.
az storage account create -g $RG -n $STG -l $LOC `
--sku Standard_LRS --kind StorageV2 --min-tls-version TLS1_2Critical — storage networking on Flex Consumption. The Function App's worker reads the deployment package and runtime state from this storage account continuously (cold start, scale-out, host bookkeeping), not just at deploy time. If the storage account's public access is fully disabled and there is no private path from the function's integration subnet, the worker can't load and the app returns 503 with an empty function list (
az functionapp function listreturns[]). See §4.1.1 below.
Add the Microsoft.Storage service endpoint to snet-function-integration, then allow that subnet on the storage account. This keeps the storage account off the public internet while letting the function worker reach it through the Azure backbone.
# 1. Service endpoint on the function subnet
az network vnet subnet update -g $RG --vnet-name $VNET -n $SUBNET_FUNC `
--service-endpoints Microsoft.Storage
# 2. Allow that subnet on the storage account, deny everything else,
# and let trusted Azure services (Functions control plane) bypass.
$SUBNET_FUNC_ID = az network vnet subnet show -g $RG --vnet-name $VNET -n $SUBNET_FUNC --query id -o tsv
az storage account network-rule add -g $RG --account-name $STG --subnet $SUBNET_FUNC_ID
az storage account update -g $RG -n $STG `
--bypass AzureServices `
--default-action Deny `
--public-network-access EnabledResult, visible under Storage account → Networking → Public network access:
- Public network access:
Enable - Public network access scope:
Enable from selected networks - Virtual Networks:
vnet-func-demo / snet-function-integrationwith Endpoint Status: Enabled
Do not flip "Public network access" to Disable unless you have first added a private endpoint for the storage account's
blobsub-resource (andfileif used), with theprivatelink.blob.core.windows.netPrivate DNS zone linked to the VNet. WithpublicNetworkAccess=Disabled, service endpoints stop working too — only private endpoints are honored. A running worker may continue to serve from a warm instance for a while, but on the next restart / cold start / scale event the app will fail to start.If you do want the fully private path (no public endpoint at all), add a private endpoint:
az network private-endpoint create -g $RG -n "pe-$STG-blob" ` --vnet-name $VNET --subnet $SUBNET_BACKEND ` --private-connection-resource-id (az storage account show -g $RG -n $STG --query id -o tsv) ` --group-id blob --connection-name "pe-$STG-blob-conn" # Private DNS zone for blob privatelink + link to the VNet az network private-dns zone create -g $RG -n "privatelink.blob.core.windows.net" az network private-dns link vnet create -g $RG -n "link-blob" ` --zone-name "privatelink.blob.core.windows.net" --virtual-network $VNET --registration-enabled false # Auto-create the A record from the PE az network private-endpoint dns-zone-group create -g $RG ` --endpoint-name "pe-$STG-blob" -n "zg-blob" ` --private-dns-zone "privatelink.blob.core.windows.net" --zone-name blob # Now safe to fully close public access az storage account update -g $RG -n $STG --public-network-access Disabled
The §4.1.1 setup (service endpoint + Enable from selected networks) keeps the storage account off the public internet for everyone except resources inside snet-function-integration, and is what this demo uses. It is secure enough for most workloads and costs nothing extra.
For a true private-only posture (storage account with publicNetworkAccess=Disabled, reachable only via private IP in your VNet), you must replace the service endpoint with private endpoints on every storage sub-resource the Flex Consumption worker uses: blob, file, queue, and table. Missing any one of them — file is the easiest to overlook — breaks worker cold start.
High-level production checklist:
- Create a dedicated
snet-private-endpointssubnet (do not put PEs in the delegatedsnet-function-integration). - Create four Private DNS zones and link them to the VNet:
privatelink.blob.core.windows.netprivatelink.file.core.windows.netprivatelink.queue.core.windows.netprivatelink.table.core.windows.net
- Create one private endpoint per sub-resource (
--group-id blob|file|queue|table) on the storage account, each wired into its matching DNS zone group. - Remove the
Microsoft.Storageservice endpoint fromsnet-function-integrationand drop the storage network rule. az storage account update --public-network-access Disabled.- Restart the Function App and verify
az functionapp function listis non-empty after the cold start.
Trade-offs vs. the demo:
- Cost: four private endpoints (~$7–8/month each in most regions) and the DNS zones, versus a service endpoint that is free.
- Operational complexity: DNS misconfiguration is the #1 cause of "the function worked yesterday and not today" in this topology — every new VNet that needs to talk to storage must have the four private DNS zones linked.
- Blast radius: matches a regulated-workload baseline (e.g. PCI/HIPAA/internal data-exfiltration controls) where any path to a public Azure endpoint is forbidden.
For this demo we deliberately stay on the simpler service-endpoint design so the walkthrough stays focused on the Fabric → MPE → Function → VM pattern. When you take this to production, swap in the private-endpoint variant above before exposing real data.
az functionapp create `
-g $RG -n $FUNC `
--storage-account $STG `
--flexconsumption-location $LOC `
--runtime python --runtime-version 3.12 `
--vnet $VNET --subnet $SUBNET_FUNC$VM1_PRIVATE_IP = "10.0.1.4" # whatever VM1 actually got
$VM2_PRIVATE_IP = "10.0.1.5" # whatever VM2 actually got
az functionapp config appsettings set -g $RG -n $FUNC --settings `
BACKEND_VM1_BASE_URL="http://$($VM1_PRIVATE_IP):8000" `
BACKEND_VM2_BASE_URL="http://$($VM2_PRIVATE_IP):8000" `
BACKEND_USERNAME="$BACKEND_USER" `
BACKEND_PASSWORD=$BACKEND_PASSEach
BACKEND_*_BASE_URLmust include the scheme and the port, and no trailing slash. The proxy uses each one both for outbound calls and as the literal string for response-body rewriting, so they must exactly match what each backend produces withrequest.base_url.
cd <repo>/src
az login # if not already
az account set --subscription $SUB
func azure functionapp publish $FUNC --pythonThe Function App now egresses from snet-function-integration (10.0.2.0/24). Add an inbound rule on the VM NSG:
az network nsg rule create -g $RG --nsg-name $VM_NSG -n allow-func-to-fastapi `
--priority 220 --access Allow --protocol Tcp --direction Inbound `
--source-address-prefixes 10.0.2.0/24 --destination-port-ranges 8000(You can keep the laptop rule for debugging, or remove it now.)
$HOSTNAME = az functionapp show -g $RG -n $FUNC --query defaultHostName -o tsv
curl.exe "https://$HOSTNAME/vm1/healthcheck"
curl.exe "https://$HOSTNAME/vm2/healthcheck"You should see each VM's JSON, with the links rewritten to point to https://$HOSTNAME/vm1/... and https://$HOSTNAME/vm2/... respectively.
Goal: have Fabric reach the Function App privately instead of over the public internet.
# Optional: lock down the public endpoint so only the MPE can reach the function.
az functionapp update -g $RG -n $FUNC --set publicNetworkAccess=Disabled- Get the resource ID of the Function App.
az functionapp show --name $FUNC --resource-group $RG --query id --output tsv- Open the Fabric workspace.
- Workspace settings → Outbound networking → Create.
- Fill in:
- Managed private endpoint name:
mpe-func-fabric-mpe-demo - Resource identifier:
<The result from the powershell command you ran at step 1> - Target sub-resource:
<It should automatically point to Azure Function>
- Managed private endpoint name:
- Click Create. State becomes Pending.
$FUNC_ID = az functionapp show -g $RG -n $FUNC --query id -o tsv
az network private-endpoint-connection list --id $FUNC_ID -o table
$PEC_ID = "<id-from-above>"
az network private-endpoint-connection approve --id $PEC_ID `
--description "Approved for Fabric MPE"Refresh the Fabric MPE page → status becomes Succeeded.
curl.exe -i "https://$HOSTNAME/vm1/healthcheck"
# Expect 403 / "Web App is stopped" — public path is closed.The notebook notebooks/fabric_call_function.ipynb (in this repo) demonstrates calling the Function App from Fabric.
Key cell — generic helper that accepts either a path or a full URL (so you can chain calls based on the links returned by the backend):
import requests
FUNCTION_BASE_URL = "https://<funcname>.azurewebsites.net"
FUNCTION_KEY = None # only if AuthLevel.FUNCTION
def call_function(target: str, *, params: dict | None = None) -> dict:
if target.startswith(("http://", "https://")):
url = target
else:
url = f"{FUNCTION_BASE_URL.rstrip('/')}/{target.lstrip('/')}"
headers = {"x-functions-key": FUNCTION_KEY} if FUNCTION_KEY else {}
r = requests.get(url, headers=headers, params=params, timeout=15)
r.raise_for_status()
return r.json()Typical chained usage:
hc_vm1 = call_function("/vm1/healthcheck")
data = call_function(hc_vm1["links"]["data_url"]) # already rewritten to /vm1/data
hc_vm2 = call_function("/vm2/healthcheck")Import the notebook into Fabric:
- Workspace → + New → Import notebook → Upload.
- Open it, set
FUNCTION_BASE_URL, run all cells. - Because the workspace has the MPE associated, the HTTPS calls go privately to the Function App, which then forwards to the VMs through VNet integration. No public internet involved.
- VNet + two subnets (backend, function-integration with delegation)
- Two Linux VMs running FastAPI under systemd, Basic auth,
/healthcheckreturninglinksto other endpoints - NSG rules: laptop (temp) and function subnet (permanent)
- Local Function project: multi-backend proxy with response-URL rewriting
- Flex Consumption Function App deployed, VNet-integrated, app settings set with private backend IPs for both VMs
- Storage account locked to
snet-function-integrationvia service endpoint (public network access = Enabled from selected networks) - Public access disabled on the Function App
- Managed Private Endpoint created in Fabric and approved
- Fabric notebook calling
/vm1/healthcheckand/vm2/healthcheckend-to-end
Switching from the demo VMs to real on-prem servers requires only:
- ExpressRoute / Site-to-Site VPN gateway in the VNet, with routes to the on-prem CIDRs.
- DNS resolution for the on-prem hostnames (custom DNS server, Private DNS Resolver, or hosts file in the function — least preferred).
- Update each
BACKEND_*_BASE_URLto the corresponding on-prem URL (HTTPS recommended; for private CAs, mount the CA bundle and sethttpx.AsyncClient(verify=...)). - Adjust on-prem firewalls to allow inbound from
snet-function-integration(10.0.2.0/24). - To add another backend, add one app setting (
BACKEND_VM3_BASE_URL=...) and one line toBACKENDSinfunction_app.py.
No changes to the proxy logic or the Fabric notebook beyond that.
