Blog Post

Apps on Azure Blog
10 MIN READ

Debugging Python apps on App Service with the new SSH helper aliases

jordanselig's avatar
jordanselig
Icon for Microsoft rankMicrosoft
May 19, 2026

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 up and 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/fault toggles one of seven modes: off, bad-creds, wrong-endpoint, dns-fail, port-mismatch, dep-import-error, latency-spike
  • GET / 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 new ai-* 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.com endpoints, both accept managed-identity bearer tokens, both speak the same schema. The aliases work against either; the env-var name AZURE_AI_FOUNDRY_ENDPOINT is 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 appCommandLine and 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:

  1. Start with apphelp and ai-diagnose every time. Don't try to remember the right command — let the aliases tell you.
  2. Treat ai-test being green as a signal, not a finish line. If /chat is red and ai-test is green, the platform path is fine; pivot to app-side aliases.
  3. Use source /home/site/diagnostics/fault.env as 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.

Updated May 19, 2026
Version 1.0
No CommentsBe the first to comment