Azure hosting options: where your code actually runs
Tell App Service, Container Apps, Functions, Static Web Apps, AKS and plain VMs apart, understand what scale-to-zero and cold starts really cost you, and work out from the files in a repo which one you're deployed on.
13 cards · 6 quiz questions · 11 min read
An agent said “I’ve deployed the API to Azure” and it worked, so you stopped asking questions. But “Azure” is not one place. It’s a menu of at least six hosting models, and the one you landed on decides whether your app sleeps when nobody’s using it, whether your in-memory cache is a real cache or a bug, and whether a six-minute export can ever finish. This article is about telling them apart from the outside — from the files in your repo — and knowing what each one quietly takes away.
The menu, honestly
Microsoft groups hosting into three shapes. IaaS (infrastructure as a service) gives you a virtual machine and you manage everything on it. PaaS (platform as a service) gives you a managed environment and you deploy an app into it. FaaS (functions as a service) takes your code and runs it on a trigger. App Service and Container Apps are PaaS; Azure Functions is FaaS; Virtual Machines are IaaS.
| Host | What you hand it | Sleeps when idle? | Good at |
|---|---|---|---|
| App Service | Code or a container | No (always on a plan you pay for) | A conventional web app or HTTP API |
| Container Apps | A container image | Yes, by default | Containers, background workers, event-driven jobs |
| Functions | Individual trigger-shaped functions | Yes, on consumption plans | Short, event-driven work: queues, timers, webhooks |
| Static Web Apps | A built front end from a Git repo | N/A (static files on a CDN) | React/Astro/Vue front ends, with a managed API |
| AKS | Containers plus Kubernetes manifests | No | Many services, when you need the Kubernetes API |
| Virtual Machines | Everything | No | Software that won’t run anywhere else |
Two entries deserve a warning. AKS is a managed Kubernetes cluster; the Architecture Center recommends six nodes for production (three system, three per user node pool) and rates the operational overhead as high — cluster management, upgrades, security. For one FastAPI service and one React front end it is nearly always the wrong answer. And Container Apps is built on Kubernetes but deliberately does not expose the Kubernetes API; if an agent tells you to run kubectl against a Container App, it has confused the two.
What “serverless” actually costs you
The pitch for scale-to-zero is that you pay nothing while idle. The bill it sends instead is statelessness. Microsoft’s comparison table lists App Service’s state management as stateless, and a Container App or function that can drop to zero replicas is stateless in a harder sense: the process holding your state is deleted.
Here is the shape of the bug an agent will hand you:
# Agent's version: an in-process cache and a counter.
_SURVEY_CACHE: dict[str, list[dict]] = {}
_CALLS_TODAY: dict[str, int] = {}
@app.get("/surveys/{survey_id}/responses")
async def responses(survey_id: str):
if survey_id not in _SURVEY_CACHE: # cache miss on every new replica
_SURVEY_CACHE[survey_id] = await fetch_responses(survey_id)
_CALLS_TODAY[survey_id] = _CALLS_TODAY.get(survey_id, 0) + 1 # per-replica, and lost at zero
return _SURVEY_CACHE[survey_id]
This is not slightly wrong, it’s wrong in two directions at once. With ten replicas the counter counts roughly a tenth of the traffic each, so a rate limit built on it lets through ten times what you intended. And when the app idles down, both dictionaries are garbage-collected along with the process.
# Better: state lives somewhere both shared and durable.
from redis.asyncio import Redis
@app.get("/surveys/{survey_id}/responses")
async def responses(survey_id: str, cache: Annotated[Redis, Depends(get_cache)]):
key = f"survey:{survey_id}:responses"
if (hit := await cache.get(key)) is not None:
return json.loads(hit)
rows = await fetch_responses(survey_id)
await cache.set(key, json.dumps(rows), ex=300) # explicit TTL, shared across replicas
await cache.incr(f"survey:{survey_id}:calls") # one counter, not one per replica
return rows
The same logic applies to anything else that lives in the process: a background thread, an APScheduler job, a file written to /tmp, a WebSocket connection registry. On App Service, note that changes outside /home don’t survive an app restart at all.
Cold starts, plainly
A cold start is the delay while the platform provisions an instance and starts your runtime for a request that arrived when nothing was running. It bites on the Functions Consumption and Flex Consumption plans and on a Container App with minimum replicas at zero. The documented cures are: always-ready (pre-provisioned) instances on Flex Consumption, perpetually warm always-ready instances on Premium, or minimum replicas of 1 or more on Container Apps. On a Dedicated plan the host runs continuously and, in Microsoft’s words, cold start isn’t really an issue.
Container Apps defaults matter here: minimum replicas 0, maximum 10, with an HTTP rule triggering on concurrent requests. And there’s a sharp edge worth memorising — if you disable ingress and set neither a minimum above zero nor a custom scale rule, the app scales to zero and has no way of starting back up. That’s exactly the shape of an agent-written background worker.
// A worker that pulls survey responses: no ingress, so it needs its own trigger.
scale: {
minReplicas: 1 // or give it a queue rule; 0 + no ingress = never runs
maxReplicas: 5
rules: [
{
name: 'responses-queue'
custom: {
type: 'azure-servicebus'
metadata: { queueName: 'responses', namespace: 'iff-bus', messageCount: '20' }
identity: 'system'
}
}
]
}
The 230-second wall
This one catches everybody. An HTTP-triggered Azure Function has roughly 230 seconds to respond, no matter what functionTimeout says in host.json, because that’s the default idle timeout of the Azure load balancer in front of it. An agent that “fixed” your document-processing timeout by raising functionTimeout to thirty minutes has changed nothing that matters.
The fix is architectural: accept the request, return a job id straight away, and do the work elsewhere.
@app.post("/exports", status_code=202)
async def start_export(req: ExportRequest, bg: BackgroundTasks) -> ExportJob:
job = await jobs.create(req.model_dump()) # Pydantic v2: model_dump, not .dict()
bg.add_task(enqueue_export, job.id) # real queue on Container Apps / Functions
return ExportJob(id=job.id, status="queued") # client polls GET /exports/{id}
Telling from the repo which one you’re on
You rarely need the portal for this. The deployment target leaves fingerprints:
host.jsonat the repo root, plus functions with triggers and bindings → Azure Functions.host.jsonholds settings for every function in the app:version,logging,extensionBundle,functionTimeout.staticwebapp.config.json, and a workflow step usingAzure/static-web-apps-deploy→ Static Web Apps. Look forapi_locationin that step: that’s the managed-Functions API.startup.sh(or any startup command, set viaaz webapp config set --startup-file), or a workflow usingazure/webapps-deploy→ App Service.- A
Dockerfileplusaz containerapp up/az containerapp update --imagein the workflow → Container Apps. - Kubernetes YAML, a Helm chart,
kubectl apply→ AKS. cloud-init, Ansible playbooks, systemd unit files → Virtual Machines.
A concrete App Service fingerprint, and the mistake agents make in it:
# startup.sh an agent wrote — this is the wrong worker class for FastAPI.
gunicorn --bind=0.0.0.0 --timeout 600 app.main:app
App Service’s built-in Python container runs Gunicorn with --bind=0.0.0.0 --timeout 600 behind Nginx, and Gunicorn’s default worker is WSGI. FastAPI is ASGI, so it needs a Uvicorn worker class:
# Correct: -k names an ASGI worker; NUM_CORES is set by App Service.
gunicorn -k uvicorn.workers.UvicornWorker \
--bind=0.0.0.0 --timeout 600 \
--workers $((($NUM_CORES*2)+1)) \
--access-logfile '-' --error-logfile '-' \
app.main:app
Without a startup command at all, App Service looks for Django (a wsgi.py), then Flask (application.py or app.py), and finding neither serves a built-in placeholder page — which is why a “successful” deploy can still show you Azure’s default site. Worse, App Service ignores errors in a custom startup command and carries on with that detection, so a typo looks like a deployment that half-worked.
One more thing nobody mentions: regions
App Service, Container Apps, Functions and AKS are all regional. Microsoft’s comparison lists each as single region only, requiring an external router (Front Door, Traffic Manager) plus a separate deployment in every region you want. If a dashboard feels sluggish for colleagues in another country, that’s not a mystery — your compute is in one place, and only Static Web Apps’ static assets are globally distributed.
What to check in your own code
- Grep the repo for
host.json,staticwebapp.config.json,startup.sh,Dockerfileand.github/workflows/*.yml. Note whichazor action verbs the workflow uses; that’s your host. - Grep for module-level mutable state:
^_[A-Z_]* *[:=] *\{,= \[\],lru_cache,APScheduler,threading.Thread. Each one is a bug on any host that can run more than one replica or scale to zero. - In the portal, open your Container App’s Scale blade and read minimum replicas. If it’s 0 and the app has no ingress, it is not running.
- Search for
functionTimeoutinhost.json. If it’s above ~4 minutes and the trigger is HTTP, the 230-second load-balancer limit still applies; find the endpoint and check whether it returns 202 with a job id. - For a Python App Service app, read the startup command in Configuration → General settings. If FastAPI is served without
-k uvicorn.workers.UvicornWorker, it isn’t being served by the command you think. - Check what writes to disk. Anything outside
/homeon App Service, or anywhere at all on a scale-to-zero host, is temporary.
An agent deployed your FastAPI app to App Service on Linux with no startup command. The site returns Azure's default placeholder page. What's the most likely cause?
Sources
- Microsoft (Azure Architecture Center) — Choose an Azure compute service website The official comparison tables for IaaS/PaaS/FaaS, state management, minimum nodes and scale limits.
- Microsoft — Azure Functions hosting options (scale and hosting) website Plan comparison, cold-start behaviour, always-ready instances and the 230-second HTTP response limit.
- Microsoft — Set scaling rules in Azure Container Apps website Default min/max replicas, the HTTP concurrency rule, cool-down period and the no-ingress scale-to-zero trap.
- Microsoft — Configure a Linux Python app for Azure App Service website The container startup sequence, the default Gunicorn command and how a custom startup file is wired up.