How Do I Deploy a FastAPI App to Azure App Service? Step by Step, From Zero

Versions used in this post — read this first: Python 3.12.3 · Azure CLI 2.91.0 · fastapi 0.162.0 · uvicorn 0.54.0 · gunicorn 26.2.0 · uvicorn-worker 0.4.0 · App Service runtime PYTHON:3.12. Everything runs from one file, main.py, printed in full below — the exact file in the companion repo. Recorded October 2026; the free-tier limits and prices below are Microsoft's published figures as of that date.

The direct answer

Deploy a FastAPI app to Azure App Service, step by step, on the free tier: put the app in a folder with requirements.txt at the root, create a resource group, a free (F1) App Service plan, and a Python 3.12 web app, set two app settings, deploy a zip of the code, and — the step most tutorials skip — set the startup command. On Python 3.13 and earlier, App Service auto-detects Django and Flask only; a FastAPI app with no startup command doesn't error, it silently serves a built-in default page. The startup command is one line, and this post explains every piece of it. At the end your app answers at https://vsl-info-api.azurewebsites.net — for $0, with a delete command that removes everything when you're done.

The finished thing first

Before any setup, here is what the finished deployment looks like. One small FastAPI app — an “App Service Info API” — answering from Azure at https://vsl-info-api.azurewebsites.net:

The deploy itself took about 2 minutes 40 seconds from zip upload to live answers, on the free F1 plan. Now, rewind.

Azure portal overview of the vsl-info-api web app, status Running, on the free F1 plan

Azure portal overview of the vsl-info-api web app, status Running, on the free F1 plan.

What you will build

  • A deployment-check API — one file, main.py, three endpoints, no database, no secrets. Nothing to leak on a screenshot.
  • The deploy — resource group, free plan, web app, app settings, zip deploy, startup command, verification from the browser and the log stream.
  • The shortcut — az webapp up, the one command that does the whole creation-and-deploy in one go, shown after the long way so you know what it did.
  • The cleanup — one command that deletes everything, and the honest note that this post's own resources were deleted after filming.

Environment setup — do this first

Prerequisites: Python 3.12 (this post uses 3.12.3), a terminal, and an Azure account. The free account is enough; everything here runs on the free F1 tier and costs $0.

Create the Azure free account, click by click:

  1. Go to azure.microsoft.com/free and choose Try Azure for free.
  2. Sign in with a Microsoft account or a GitHub account (create one if you don't have one).
  3. Fill in the profile details.
  4. Identity verification: a phone number and a credit card or non-prepaid debit card. The card is only checked for identity — a ~$1 temporary authorization hold may appear and is removed. You are charged nothing unless you later choose pay-as-you-go pricing.
  5. Accept the agreement and finish.

The offer, as Microsoft documents it: a US$200 credit for your first 30 days, plus amounts of popular services free for 12 months (and some always free). If you don't upgrade within 30 days, or when the credit runs out, the account and services are disabled until you do — nothing keeps billing in the background. This tutorial never needs the credit at all: the F1 plan is $0, and the free account exists here only as the door in.

Install the Azure CLI, then sign in:

Install the Azure CLI for your operating system from Microsoft's install page (learn.microsoft.com — “Install the Azure CLI”), then verify and sign in:

az --version        # expect azure-cli 2.91.0
az login            # opens a browser sign-in — complete it there
az account show     # read this output: it names the subscription
                    # you are actually about to deploy to

Two optional checks while you're here. The authoritative list of Python runtimes App Service currently offers is a command, not a page — run it and note that PYTHON:3.12 is in it:

az webapp list-runtimes --os-type linux --runtime python --output table

(At this post's production run, on October 28, 2026, the Python rows of that output were PYTHON|3.15, PYTHON|3.14, PYTHON|3.13, PYTHON|3.12, and PYTHON|3.11, all Active, plus PYTHON|3.10, marked Near end of life (support ends 2026-10-31). The list moves — run the command rather than trusting any printed copy, including this one.)

Create the Python environment and run the app locally first:

mkdir fastapi-info && cd fastapi-info
python3 -m venv .venv && source .venv/bin/activate
# Windows PowerShell: py -m venv .venv ; .venv\Scripts\Activate.ps1
pip install -r requirements.txt

(The code goes in this folder as main.py and requirements.txt — both printed in full below, and the companion repo has them as ready-made files.)

uvicorn main:app --reload

Open http://127.0.0.1:8000/ — it answers "environment": "local", "hostname": "localhost". Try /health and /greet/YourName too. Do not skip the local run: it separates “my code is broken” from “my deploy is broken” before Azure is involved. The local run for this post, real output:

{"app":"App Service Info API","message":"Hello from FastAPI","environment":"local","hostname":"localhost"}

Verify-it-works checkpoint: local app answers at http://127.0.0.1:8000/, and az account show names the subscription you intend to use. Both on screen before you touch Azure's portal.

The app — one file, three endpoints

"""App Service Info API: the smallest app that proves a deploy worked.

Companion code for the Visual Studio Learn post and video
"How Do I Deploy a FastAPI App to Azure App Service? Step by Step,
From Zero".

This one-file FastAPI app exists to make deployment concepts visible:

- GET /        reports where the code is running. It reads the
               APP_ENVIRONMENT app setting (which we set to
               "production" in Azure) and WEBSITE_HOSTNAME, a variable
               App Service defines automatically. Locally, with neither
               set, the same code answers "local" — the before/after
               contrast is the whole demo.
- GET /health  status, the Python version, and the current UTC time:
               the endpoint you hit to prove the app is really up.
- GET /greet/{name}
               a personalised greeting — proof that routing and path
               parameters survived the trip to the cloud.

Requires: Python 3.10 or newer and the packages in requirements.txt.
Run locally:  uvicorn main:app --reload
In Azure it is served by Gunicorn with the Uvicorn worker class — see
startup.txt, which holds the exact command the post sets in App
Service.
"""

import logging
import os
import platform
from datetime import datetime, timezone

from fastapi import FastAPI

logger = logging.getLogger("info_api")
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

app = FastAPI(title="App Service Info API")


def environment() -> str:
    """Where this process believes it is running.

    APP_ENVIRONMENT is an ordinary app setting: unset locally (so the
    default "local" answers), set to "production" in Azure.
    """
    return os.environ.get("APP_ENVIRONMENT", "local")


def hostname() -> str:
    """The site's public hostname, when the platform provides one.

    App Service auto-defines WEBSITE_HOSTNAME for every web app.
    Locally it does not exist, so the honest answer is "localhost".
    """
    return os.environ.get("WEBSITE_HOSTNAME", "localhost")


@app.get("/")
def read_root() -> dict:
    """Say hello, and say where from — the deploy's proof page."""
    return {
        "app": "App Service Info API",
        "message": "Hello from FastAPI",
        "environment": environment(),
        "hostname": hostname(),
    }


@app.get("/health")
def health() -> dict:
    """Liveness plus runtime identity: status, Python version, UTC now."""
    return {
        "status": "ok",
        "python": platform.python_version(),
        "utc_time": datetime.now(timezone.utc).isoformat(),
    }


@app.get("/greet/{name}")
def greet(name: str) -> dict:
    """A greeting with the visitor's name — routing, proven live."""
    return {
        "greeting": f"Hello, {name}!",
        "environment": environment(),
        "hostname": hostname(),
    }

Two design choices to notice. The app reads app settings as ordinary environment variables — that is how App Service configuration reaches code, and it is why setting APP_ENVIRONMENT later flips the answer. And it has no database, no key, no secret: the demo's only moving parts are the deploy itself.

The two files Azure cares about

requirements.txt — at the project root, or the build fails:

fastapi==0.162.0
uvicorn==0.54.0
gunicorn==26.2.0
uvicorn-worker==0.4.0

App Service's build looks for the dependency file at the root of the project (or the zip). Without it, the build reports: “Could not find setup.py or requirements.txt; Not running pip install.” That error text, verbatim, is the giveaway that the file is in the wrong place.

startup.txt — the startup command, the step most tutorials skip:

gunicorn --bind=0.0.0.0:8000 --timeout 600 --workers 2 -k uvicorn_worker.UvicornWorker main:app --access-logfile '-' --error-logfile '-'

Piece by piece, because each one is a decision:

  • Why Gunicorn at all? App Service's built-in Python container already runs apps under Gunicorn, behind an Nginx reverse proxy. The startup command customises that, it doesn't replace the platform.
  • Why can't Gunicorn run FastAPI alone? Its default worker is a synchronous WSGI worker. Pointed at an ASGI app, it fails in the log stream with TypeError: FastAPI.__call__() missing 1 required positional argument: 'send'. The fix is the Uvicorn worker class, which speaks ASGI.
  • Why uvicorn_worker.UvicornWorker, not the uvicorn.workers class older tutorials show? In uvicorn 0.54.0 the uvicorn.workers module is deprecated — it still runs, but it logs a deprecation warning pointing at the new uvicorn-worker package. This post uses the new class; the old one is the fallback an older tutorial's command will show you.
  • Why port 8000? The built-in Python image exposes port 8000 by default. The app must listen there.
  • Why 2 workers, not the 4 many tutorials show? The free F1 plan has 1 GB of RAM shared across the plan. Two workers leave headroom; four would not teach you anything extra.
  • What is not in this command, deliberately: WEBSITES_PORT. That app setting is for custom containers, not the built-in images — it has no effect inside the container, and setting it is cargo-cult configuration this post corrects on sight.

And the callout that decides the whole walkthrough: on Python 3.14 and later, App Service auto-detects and runs FastAPI apps with no startup command at all. We pin Python 3.12 anyway, because 3.12 is a currently documented runtime (Microsoft's own PostgreSQL tutorials run on it) and because the startup command is the skill every existing app still needs. If you deploy on 3.14, skip the startup-command step — and know that skipping it is the platform's new behaviour, not a shortcut you invented.

Step 1 — Create the resources (portal and CLI, side by side)

The names below are the exact ones from this post's own run — resource group rg-vsl-topic05-cus, plan plan-vsl-topic05, app vsl-info-api. Pick your own equivalents; the app name in particular must be globally unique across Azure, because it becomes your public address.

Portal: Azure portal → Create a resource → Web App. Resource group: create new, rg-vsl-topic05-cus. Region: Central US — on a brand-new free account the region is not a free choice; see the quota note under the CLI commands below. Publishing model: Code. Runtime stack: Python 3.12. Operating system: Linux (Python on App Service runs on Linux only — Windows Python is no longer supported). Pricing plan: create new, and choose the Free (F1) tier. Review + create.

CLI — the same resources, in three commands:

# A resource group to hold everything (delete the group = delete all)
az group create --name rg-vsl-topic05-cus --location centralus

# The FREE plan — the --sku F1 part is what keeps this at $0
az appservice plan create --name plan-vsl-topic05 \
  --resource-group rg-vsl-topic05-cus --sku F1 --is-linux \
  --location centralus

# The web app on Python 3.12 (the name must be globally unique;
# the app's public address becomes <name>.azurewebsites.net)
az webapp create --name vsl-info-api --resource-group rg-vsl-topic05-cus \
  --plan plan-vsl-topic05 --runtime "PYTHON:3.12"

Confirm the runtime landed where you think it did:

az webapp config show --name vsl-info-api --resource-group rg-vsl-topic05-cus \
  --query linuxFxVersion

The region note, from this post's own run — read it before you pick a region. This walkthrough was produced on a brand-new free-trial account, and creating the F1 plan failed in Canada Central, East US, and West US 2: the account's free-tier quota there was 0, and the error said so. Central US had quota and accepted the plan immediately. If your region refuses the free plan, you have two free moves: switch regions (Central US worked here), or request the free quota increase for your subscription. What not to do is “fix” it by choosing a paid tier — the quota is an account-youth quirk, not a price of entry.

The rg-vsl-topic05-cus resource group holding the plan and the web app

The rg-vsl-topic05-cus resource group holding the plan and the web app.

The plan-vsl-topic05 App Service plan page showing the Free F1 pricing tier

The plan-vsl-topic05 App Service plan page showing the Free F1 pricing tier.

Step 2 — App settings

Two settings, both set before the deploy:

az webapp config appsettings set --name vsl-info-api \
  --resource-group rg-vsl-topic05-cus \
  --settings SCM_DO_BUILD_DURING_DEPLOYMENT=true APP_ENVIRONMENT=production

(Portal: your web app → Settings → Environment variables → App settings tab, add both, Save. Older portal versions called this Configuration → Application settings.)

  • SCM_DO_BUILD_DURING_DEPLOYMENT=true tells the deployment to run the Oryx build: activate a virtual environment and pip install requirements.txt. A raw zip deploy must set this itself — without it, the classic symptom is ModuleNotFoundError at startup, because the packages were never installed.
  • APP_ENVIRONMENT=production is the / endpoint's proof: it is what flips the local answer to the Azure one.
App settings showing APP_ENVIRONMENT set to production

App settings showing APP_ENVIRONMENT set to production.

Step 3 — Deploy the zip

Build the zip with the dependency file at the archive root — and no .venv, no .git, no .env inside it. Deploying your local virtual environment is the most common self-inflicted deploy failure in this whole walkthrough:

zip deploy.zip main.py requirements.txt startup.txt
az webapp deploy --name vsl-info-api --resource-group rg-vsl-topic05-cus \
  --type zip --src-path deploy.zip

Deployment can take a few minutes; the production run for this post took about 2 minutes 40 seconds.

Deployment Center log showing the zip deployment succeeded

Deployment Center log showing the zip deployment succeeded.

Then set the startup command before you judge the result: portal → your web app → Configuration → Stack settings tab → Startup Command, paste the line from startup.txt, Save, then restart the app and wait ~20 seconds. (The General settings tab next to it holds platform and debugging options — the startup command is not there.)

Configuration, Stack settings tab, with the gunicorn startup command for FastAPI

Configuration, Stack settings tab, with the gunicorn startup command for FastAPI.

Step 4 — The staged failure (deliberate, and the point of the post)

Before the startup command was set, the first deploy of this exact app did not error. It served the platform's built-in default page — the generic one titled “Microsoft Azure App Service - Welcome” — because App Service auto-detects Django and Flask only, and a FastAPI app with no startup command falls through to the platform's read-only default app. We reproduced this on purpose during production, by clearing the startup command on the working app and restarting it: same deploy, same code, default page — then restored the command and watched the real app come back. If you see a default page after a deploy that reported success, you have not failed: you have reproduced the platform's documented behaviour, and the fix is the startup command plus a restart.

After the restart, the same URL answers the real app. Verify like an engineer — all three endpoints, in the browser:

Then read the log stream while you hit the app, so the deploy stops being a black box:

az webapp log config --name vsl-info-api --resource-group rg-vsl-topic05-cus
az webapp log tail --name vsl-info-api --resource-group rg-vsl-topic05-cus

The shortcut — az webapp up

Everything above, in one command, for a fresh folder:

az webapp up

az webapp up is a current, generally available Azure CLI command: it creates the resource group, plan, and web app and deploys the code from your local folder, and Python apps are created as Linux apps. Use it when you already understand the steps — the long way above is the version that teaches you what the shortcut did, including which startup command and app settings it leaves you still needing to set yourself.

Errors you may hit

  • The site shows a default/placeholder page, not your app — the page titled “Microsoft Azure App Service - Welcome”. FastAPI wasn't detected (no startup command on Python 3.13 or earlier) and App Service ran its built-in default app. Fix: set the startup command, restart, wait ~20 seconds. One related trap, learned on this post's own run: az webapp config set --startup-file "" looks like it clears the startup command but silently leaves the old one in place — to actually clear it, use the portal's Configuration page or the management API.
  • TypeError: FastAPI.__call__() missing 1 required positional argument: 'send' in the log stream — Gunicorn's default sync worker met an ASGI app. Fix: the Uvicorn worker class in the startup command (Step: the two files Azure cares about). This exact error is why that class is in the command.
  • “Could not find setup.py or requirements.txt; Not running pip install.” — the dependency file is not at the project root / zip root. Fix: move it to the root and redeploy.
  • ModuleNotFoundError at startup — the packages were never installed, usually because the build never ran or a local venv was deployed with the code. Fix: set SCM_DO_BUILD_DURING_DEPLOYMENT=true and redeploy; never ship your venv.
  • “Service Unavailable” / the first hit hangs — two possible causes. The mild one is a cold start: F1 has no Always On, the app unloads when idle, and Microsoft's docs warn the app “might take longer to start up when you use free tiers… and become responsive after you refresh”. The serious one is Gunicorn up but the app not answering — check the log stream before changing anything (az webapp log tail).
  • The app stops responding part-way through the day — the F1 60-CPU-minutes-per-day quota is exhausted. It resets the next day. That is the free tier's ceiling working as designed; the first paid tier removes it.
  • A DeprecationWarning about uvicorn.workers in the logs — not a failure. You (or an older tutorial) used the class from the old location; uvicorn 0.54.0 says it moved to the uvicorn-worker package. Switch before a future uvicorn removes it.
  • The container perpetually restarts — the platform's warmup request must get some HTTP status back. An app that never answers (wrong port, crash on import) restarts forever. Slow starters get a lever: the WEBSITES_CONTAINER_START_TIME_LIMIT app setting (default 230 seconds, maximum 1800).

What it costs

F1 is $0 — and it is a real, currently listed plan, intended for trials, experimentation, and learning the service, with the limits to match:

F1 (Free) limitsValue
ComputeShared, 60 CPU minutes per day (also capped at 3 minutes per 5 minutes)
Memory1,024 MB per plan
Storage1 GB per plan
Apps per plan10
Bandwidth165 MB per day
Custom domains0 — the *.azurewebsites.net name only (its HTTPS certificate is included)
Always OnNo — the app unloads when idle
SLANone

The first paid step up, B1 in Central US, was US$0.018 per hour — about US$13.14 per month — when this post was prepared (Microsoft's Retail Prices API, October 2026); B1 gives you a dedicated core, 1.75 GB RAM, and 10 GB storage. (For contrast, Windows B1 in the same region was US$0.075/hour — the Linux/Windows gap is one reason this walkthrough is Linux-only, beside Linux being the only option for Python.)

One billing warning, quoted from Microsoft's pricing page FAQ because people learn it the expensive way: “Rates listed apply to apps in stopped state. Please delete apps that are not in use or update tier to Free to avoid charges.” App Service plans are charged while they exist, including stopped apps. Deleting is the reliable off switch — which is the next section.

Delete it when you're done (yes, really)

Everything in this walkthrough lives in one resource group, so one command removes all of it — the plan, the app, the settings, everything:

az group delete --name rg-vsl-topic05-cus --yes

Two honest notes to close the cost story. First: this post's own deployment was deleted this way after filming. The video's live site is gone by the time you watch it — that is the owner's rule for this channel (free tier only, and delete resources when you're not using them) applied to itself, not a gap in the tutorial. Second: nothing is lost by deleting. The companion repo folder plus the commands in this post redeploy the app from scratch any time you want it back, in the same sitting. Treat free-tier resources as disposable by default, and the cloud bill for learning stays exactly $0.

Where to go next

This same one-file app is the running example for the rest of the ship-and-run mini-series: containerise it with Docker (Topic #9), deploy it on every push with GitHub Actions through Deployment Center — which commits a workflow file to your repo and authenticates with a Microsoft Entra identity (Topic #13) — and watch it with Prometheus and Grafana (Topic #10). On App Service itself, the next steps in order: a custom domain and its certificate when you move to a Basic-or-higher plan (the free tier supports none), and the log stream as a daily habit rather than an emergency tool.

Get the code

The complete main.py, pinned requirements.txt, the startup.txt command file, and this post's setup as a README live in the companion repository:
https://github.com/sajjad037/visual-studio-learn-code/tree/main/topic-05-deploy-fastapi-azure

Sources

Enjoying these tutorials?

Get new Visual Studio Learn posts by email — free, no spam:

Get new posts by email:

Follow along: YouTube · Facebook · RSS

Comments