🪴 GoDeep
Search
← Shipping Software

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.

HostWhat you hand itSleeps when idle?Good at
App ServiceCode or a containerNo (always on a plan you pay for)A conventional web app or HTTP API
Container AppsA container imageYes, by defaultContainers, background workers, event-driven jobs
FunctionsIndividual trigger-shaped functionsYes, on consumption plansShort, event-driven work: queues, timers, webhooks
Static Web AppsA built front end from a Git repoN/A (static files on a CDN)React/Astro/Vue front ends, with a managed API
AKSContainers plus Kubernetes manifestsNoMany services, when you need the Kubernetes API
Virtual MachinesEverythingNoSoftware 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.json at the repo root, plus functions with triggers and bindings → Azure Functions. host.json holds settings for every function in the app: version, logging, extensionBundle, functionTimeout.
  • staticwebapp.config.json, and a workflow step using Azure/static-web-apps-deploy → Static Web Apps. Look for api_location in that step: that’s the managed-Functions API.
  • startup.sh (or any startup command, set via az webapp config set --startup-file), or a workflow using azure/webapps-deploy → App Service.
  • A Dockerfile plus az containerapp up / az containerapp update --image in 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, Dockerfile and .github/workflows/*.yml. Note which az or 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 functionTimeout in host.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 /home on App Service, or anywhere at all on a scale-to-zero host, is temporary.

Sources