You shipped a Python app to App Service. It worked in the demo. It works locally. In production, /chat is returning 502s — but /health is green, the deployment succeeded, the logs are quiet, and your laptop can't reproduce it.
What you actually need is a shell on the running container so you can poke at DNS, env vars, installed packages, the listening port, and the AI endpoint your app is calling. The platform has had SSH for a while, but the playbook of "open SSH, then remember which 14 commands to run" was tribal knowledge.
We just shipped a set of SSH helper aliases that turn that tribal knowledge into one-word commands. apphelp shows you everything; appconfig, showpkgs, and appcurl cover the app side; ai-test, ai-diagnose, ai-curl, ai-latency, ai-dns, and ai-access-check cover the Azure AI Foundry side.
This post is a hands-on tour. We built a deliberately fragile FastAPI sample with six different fault modes, deployed it, broke it, and SSH'd in to watch the aliases drive each one to root cause. Every transcript below is real output from the deployed sample.
📦 Sample repo: seligj95/app-service-ssh-diagnostics-python —
azd upand you have a fault-injectable Python + Foundry app live in your subscription in about 4 minutes.
The sample, in one breath
-
FastAPI app, Python 3.14, App Service Linux on P0v3 — uses the new Oryx FastAPI auto-detection so no custom startup command is needed
- Calls Azure OpenAI (gpt-4o-mini) via managed identity — no keys
POST /admin/faulttoggles one of seven modes:off,bad-creds,wrong-endpoint,dns-fail,port-mismatch,dep-import-error,latency-spikeGET /is a landing page with a built-in cheat sheet of the SSH aliases
The endpoints are intentionally boring. The point is to give the aliases something realistic to chew on.
A quick note on Azure OpenAI vs. AI Foundry. This sample provisions an Azure OpenAI account (
kind: OpenAI). The newai-*aliases speak the OpenAI chat-completions API (/openai/deployments/<model>/chat/completions), which is identical on Azure OpenAI and on Azure AI Foundry projects — both expose*.openai.azure.comendpoints, both accept managed-identity bearer tokens, both speak the same schema. The aliases work against either; the env-var nameAZURE_AI_FOUNDRY_ENDPOINTis just the alias contract. Drop a Foundry endpoint into it and the same walkthrough applies.
Shout-out to the new FastAPI auto-detect on Python 3.14. This sample also benefits from another recent App Service change: on Python 3.14+, App Service automatically detects FastAPI apps and starts them with gunicorn -k uvicorn_worker.UvicornWorker — no custom startup command needed. Our Bicep ships an empty
appCommandLineand lets Oryx do the right thing. The whole sample is a nice tour of recent App Service Python improvements landing together.
Step zero: apphelp
After azd up finishes, the first thing to do over SSH is:
az webapp ssh -g rg-ssh-diag-demo -n app-web-<token>
Then inside the container:
$ apphelp
apphelp prints every alias the image ships with, grouped by category. You don't need to memorize anything — when you forget what checkport does, you run apphelp and it's right there. We'll lean on most of these:
- App info:
showpkgs,appconfig,appenv - Logs:
applogs,deploylogs,logfiles - Reachability:
appcurl,checkport,gohome,gosrc - AI/Foundry:
ai-test,ai-dns,ai-access-check,ai-curl,ai-latency,ai-diagnose - Network tools:
install-nettools
The healthy baseline
Before breaking anything, run ai-diagnose. This is the one-shot "is my AI path healthy?" check, and it's the alias we reach for most:
$ ai-diagnose
────────────────────────────────────────────────────────────────
AI Foundry Diagnostics
────────────────────────────────────────────────────────────────
[✓] Managed identity token
[✓] DNS resolution (d8f9grasb7ewc7h8.ai-gateway.eastus2-01.azure-api.net. - public)
[✓] Foundry connectivity (761ms)
────────────────────────────────────────────────────────────────
Three green checks tell you three different things: the managed identity is issuing tokens, the Foundry hostname resolves, and the endpoint responded in a reasonable time. If any of these are red, you already know which layer the fault is in.
For more detail, the individual aliases are worth knowing:
$ ai-test
✓ Connected | 1009ms | Model: gpt-4o-mini | Auth: Managed Identity
$ ai-access-check
✓ Foundry endpoint: https://cog-ftirxupt2yjoe.openai.azure.com/
✓ Model: gpt-4o-mini
✓ Using auth mode: Managed Identity
✓ Access check passed: authorized to call Foundry
$ ai-latency
Running 5 requests to gpt-4o-mini...
Request 1: 679ms ✓
Request 2: 826ms ✓
Request 3: 758ms ✓
Request 4: 641ms ✓
Request 5: 664ms ✓
Results (5/5 successful):
Avg: 713ms | Min: 641ms | Max: 826ms
And the app side:
$ checkport
✓ App is listening on port 8000
$ appcurl /health
HTTP Status: 200
Time: 0.002417s
Size: 5423 bytes
That's our "everything is fine" reference. Now let's break things.
One trick: applying a fault inside the SSH shell
A subtle thing trips people up the first time. POST /admin/fault mutates the app process's environment — but your SSH shell is a separate process. It inherited the container's env when you opened the session, so ai-test will still see the healthy values.
The sample handles this by also writing a small file to the persistent share:
# app/faults.py
def _write_env_file() -> None:
"""Write fault env to /home/site/diagnostics/fault.env so SSH can `source` it."""
diag = Path("/home/site/diagnostics")
diag.mkdir(parents=True, exist_ok=True)
snap = _snapshot_unlocked()
lines = [f"# Active fault: {snap['mode']}", ""]
for k, v in snap["env"].items():
lines.append(f"export {k}={shlex.quote(v) if v else "''"}")
(diag / "fault.env").write_text("\n".join(lines) + "\n")
After toggling a fault, run this once in your SSH session:
source /home/site/diagnostics/fault.env
Now the aliases see the same env the broken app sees. This pattern — flip a flag from outside, source the change inside — is worth stealing for your own debugging workflows.
Group A: faults the AI aliases catch directly
Some faults are in the path between App Service and Foundry — wrong endpoint, broken DNS, network. The ai-* aliases reproduce the failure end-to-end, and they tell you exactly which layer.
Fault 1: wrong-endpoint — a typo in the AOAI endpoint
The most common AI-side incident: someone fat-fingers an app setting. The endpoint resolves to something (it's still *.openai.azure.com) but it's not your resource.
curl -X POST $URL/admin/fault -H 'content-type: application/json' \
-d '{"mode":"wrong-endpoint"}'
curl $URL/chat -H 'content-type: application/json' \
-d '{"prompt":"hi"}'
# HTTP 502
# {"detail":"APIConnectionError: Connection error."}
SSH in, source the fault env, run the AI aliases:
$ source /home/site/diagnostics/fault.env
$ ai-dns
Resolving: this-resource-does-not-exist.openai.azure.com
✗ DNS resolution failed for this-resource-does-not-exist.openai.azure.com
$ ai-curl
Request:
POST https://this-resource-does-not-exist.openai.azure.com//openai/deployments/gpt-4o-mini/chat/completions?api-version=2024-02-01
Authorization: Bearer [hidden]
Content-Type: application/json
curl: (6) Could not resolve host: this-resource-does-not-exist.openai.azure.com
$ ai-diagnose
[✓] Managed identity token
[✗] DNS resolution failed for this-resource-does-not-exist.openai.azure.com
[✗] Foundry connectivity (HTTP 000)
ai-diagnose collapses the whole story into three lines: token works, DNS fails, connectivity fails. The fault is unambiguously a bad endpoint — check appconfig and your Bicep parameters.
Fault 2: dns-fail — NXDOMAIN
A subtler variant of the same failure mode is when the endpoint is structurally wrong (private endpoint misconfigured, hosts file mishap, custom domain expired). ai-dns calls it out the same way:
$ ai-dns
Resolving: no-such-host.invalid.example
✗ DNS resolution failed for no-such-host.invalid.example
If you need deeper diagnostics — say, you suspect a flaky resolver rather than the hostname itself — install-nettools gives you dig, nslookup, and friends without rebuilding the container.
$ install-nettools
$ dig openai.azure.com
$ nslookup cog-ftirxupt2yjoe.openai.azure.com
Group B: faults that pass ai-test but break your app
Here's the most useful thing we learned building this sample: ai-test can be green while your app is on fire, and that's a signal, not a bug.
The ai-* aliases call Foundry directly. If they're green and your app is red, the platform-to-Foundry path is fine — the divergence is in your app. Time to pivot to appenv, applogs, showpkgs.
Fault 3: bad-creds — wrong AZURE_CLIENT_ID
This one is the classic user-assigned managed identity mishap: you scoped your code to a user-assigned managed identity, but the GUID in AZURE_CLIENT_ID doesn't actually exist (or wasn't granted RBAC).
curl -X POST $URL/admin/fault -d '{"mode":"bad-creds"}'
curl $URL/chat -d '{"prompt":"hi"}'
# HTTP 502
# {"detail":"ClientAuthenticationError: DefaultAzureCredential failed to retrieve a token..."}
Now SSH in and try the AI aliases:
$ source /home/site/diagnostics/fault.env
$ ai-test
✓ Connected | 734ms | Model: gpt-4o-mini | Auth: Managed Identity
$ ai-access-check
✓ Foundry endpoint: https://cog-ftirxupt2yjoe.openai.azure.com/
✓ Using auth mode: Managed Identity
✓ Access check passed: authorized to call Foundry
Both green. That looks like a contradiction, but it's not. The aliases authenticate using the system-assigned managed identity directly (via IMDS), and they pass. Your Python app uses DefaultAzureCredential, which honors AZURE_CLIENT_ID to pick a user-assigned identity — and that one is broken.
The takeaway: when ai-test is green but /chat is red, the platform's identity is fine. Pivot to appenv to see exactly what env your app process sees, and check AZURE_CLIENT_ID:
$ appenv | grep AZURE_CLIENT_ID
AZURE_CLIENT_ID=00000000-0000-0000-0000-000000000000
There's the bug. The aliases didn't fail — they told you the fault isn't in the platform. That's diagnosis by elimination, and it's faster than guessing.
Fault 4: dep-import-error — your code throws
Same pattern. The app raises an ImportError on /chat, the AI aliases are green:
curl -X POST $URL/admin/fault -d '{"mode":"dep-import-error"}'
curl $URL/chat -d '{"prompt":"hi"}'
# HTTP 500
# {"detail":"ImportError: No module named 'tiktoken'..."}
This is where the app-side aliases earn their keep:
$ showpkgs | head -20
──────────────────────────────────────────────────────
Virtual environment packages (antenv)
──────────────────────────────────────────────────────
Package Version
-------------------------------------- ---------
annotated-types 0.7.0
anyio 4.13.0
azure-core 1.41.0
azure-identity 1.19.0
azure-monitor-opentelemetry 1.8.8
...
No tiktoken in that list. Confirmation in one command — no need to remember pip list or where the virtualenv lives.
deploylogs then tells you what the last deployment actually built:
$ deploylogs 10
Latest deployment: b8a64ed4-b6b7-4419-91eb-6d8e4e7ef323
Log file: /home/site/deployments/b8a64ed4-b6b7-4419-91eb-6d8e4e7ef323/log.log
2026-05-18T19:10:52.3844297Z,Parsing the build logs,abc3cf97-...
2026-05-18T19:10:52.5414396Z,Found 0 issue(s),7d11d013-...
2026-05-18T19:10:52.7913394Z,Build Summary :,...
2026-05-18T19:10:53.5643089Z,Deployment successful. deployer = Push-Deployer ...
Build was clean. The package just isn't in requirements.txt. Two aliases, one minute, root cause.
Fault 5: port-mismatch — uvicorn binds the wrong port
A real-world bug: someone sets WEBSITES_PORT=9999 in app settings to expose a different port, but the app still binds to 8000.
curl -X POST $URL/admin/fault -d '{"mode":"port-mismatch"}'
The aliases tell you exactly which port everything sees:
$ checkport
Checking if app is listening on port 8000...
✓ App is listening on port 8000
$ appcurl /health
Testing app at localhost:8000 ...
HTTP Status: 200
Time: 0.002417s
$ appconfig
PORT
Value: 8000
Note: The port your Python app should listen on. Default is 8000.
The app is healthy from inside the container. The mismatch is between what the platform tries to forward to and what uvicorn is bound to. This is the kind of fault where curling the public URL fails but appcurl /health succeeds — and the contrast is itself the diagnosis.
Fault 6: latency-spike — the alias bench is fast, your app is slow
The app injects 4 seconds of asyncio.sleep before each Foundry call. /chat is now ~4.5 seconds. ai-latency:
$ ai-latency
Running 5 requests to gpt-4o-mini...
Request 1: 715ms ✓
Request 2: 588ms ✓
Request 3: 578ms ✓
Request 4: 669ms ✓
Request 5: 643ms ✓
Results (5/5 successful):
Avg: 638ms | Min: 578ms | Max: 715ms
Foundry, from this instance, averages 638ms. If your app is taking 5 seconds end-to-end and ai-latency says the model is sub-second, the slowness is in your code — not in Foundry, not in the network. Time to look at App Insights end-to-end transactions, or at any pre-call work (retrieval, vector lookup, your own sleep).
What this changes about the debugging workflow
Before these aliases, the SSH playbook for a Python AI app went something like: open SSH, dig around /home/site/wwwroot/antenv, grep applicationHost.config for ports, write a curl by hand against the AOAI endpoint with a manually-fetched managed identity token, hope you got the API version right.
Now it's ai-diagnose. If that's red, you know exactly which layer. If it's green, you know the fault is in your code or your settings, and appenv, appconfig, showpkgs, applogs walk you the rest of the way.
Three patterns we'd lean on going forward:
- Start with
apphelpandai-diagnoseevery time. Don't try to remember the right command — let the aliases tell you. - Treat
ai-testbeing green as a signal, not a finish line. If/chatis red andai-testis green, the platform path is fine; pivot to app-side aliases. - Use
source /home/site/diagnostics/fault.envas a pattern. Any time you want your SSH shell to see what the app process sees, write env to a file and source it. It's a small thing that removes a huge class of "but it worked when I tested it" confusions.
We want feedback
The aliases are GA today on Python images and we have ideas for where they go next — Node, .NET, more ai-* checks (Foundry agents, vector indexes), tighter integration with azd diagnose. If you have a Python app on App Service and you want a specific alias added, tell us by dropping a comment on this post.
Try the sample
git clone https://github.com/seligj95/app-service-ssh-diagnostics-python
cd app-service-ssh-diagnostics-python
azd auth login
azd up
Four minutes later you'll have the whole thing live. Then curl -X POST $URL/admin/fault -d '{"mode":"<pick one>"}', SSH in, and walk through any of the six faults above. The README has the full alias-to-fault map.