Where does this actually run?
Build machine, CI runner, server process, container and edge isolate are five different computers — learn to tell which of your code runs on which, and which bugs come from getting that wrong.
12 cards · 6 quiz questions · 10 min read
An agent built you a dashboard. It works on your laptop. Deployed, the front end loads but every API call returns 500; a rate limit that should allow five exports an hour allows fifteen; a CSV the nightly job writes is sometimes there and sometimes not. None of these are logic bugs. They are all the same mistake: code written as though there were one computer, running on five.
The five are worth naming, because almost every deployment bug is a fact that is true on one of them and false on another.
| Machine | What it does | What it has |
|---|---|---|
| Your laptop | Runs dev servers with hot reload | Your .env, your local database, everything you ever installed |
| The CI runner | Installs, tests, builds artefacts, then is destroyed | Nothing except what the workflow installs and the secrets you gave it |
| The server process | Handles live requests | The real secrets, no browser, possibly several copies of itself |
| The static host or CDN | Sends built files to browsers | No ability to compute anything |
| The user’s browser | Executes every line of your React code | The user’s network, and full visibility of your bundle |
Build time and run time are different moments
A React app is not “running” on a server anywhere. A build turns your source into a folder of static files, once, on a build machine. Those files are then copied to a host that does nothing but serve bytes. Every browser that loads the page executes that same frozen output.
The consequence people get wrong: anything the bundler can see at build time is baked in permanently.
// Before — an agent's "quick" fix so the dashboard can call the LLM directly.
// import.meta.env.VITE_* is REPLACED with the literal string during the build,
// so this key is now a plain string inside a file anyone can download.
const key = import.meta.env.VITE_AZURE_OPENAI_KEY;
const res = await fetch("https://iff.openai.azure.com/…", {
headers: { "api-key": key },
});
// After — the browser calls your own endpoint; the key never leaves the server.
const res = await fetch("/api/summarise", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ surveyId }), // the key lives in the FastAPI process
});
Vite exposes only VITE_-prefixed variables to client code, and the prefix is precisely the marker that says “this is public”. You can prove the leak in ten seconds:
pnpm build
grep -ro "sk-[A-Za-z0-9]\{8,\}" dist/ | head # any hit is a published secret
grep -rno "VITE_[A-Z_]*" dist/assets/*.js | head
The same logic explains why changing an API base URL in your host’s settings does nothing to a deployed front end. Server-side environment variables are read at run time, so a setting change plus a restart is enough. Front-end values are compiled in, so they need a rebuild and a redeploy.
How a FastAPI process handles a request
On the server side there is a real, long-lived process. Uvicorn (an ASGI server) listens on a port, accepts a connection, parses the HTTP request, and calls your app object with it. Your route function runs on an event loop: a single thread that interleaves many requests by switching whenever one of them waits on I/O.
That design is why one rule matters more than any other in a FastAPI codebase:
# Before — blocks the whole event loop. Every other request waits behind this one.
import requests
@router.get("/responses/{survey_id}")
async def get_responses(survey_id: int):
r = requests.get(f"{UPSTREAM}/surveys/{survey_id}") # synchronous: no await point
return r.json()
# After — awaits, so the loop serves other requests while this one waits.
@router.get("/responses/{survey_id}")
async def get_responses(
survey_id: int,
client: Annotated[httpx.AsyncClient, Depends(get_client)], # reused, pooled
):
r = await client.get(f"{UPSTREAM}/surveys/{survey_id}", timeout=10)
r.raise_for_status()
return r.json()
If a function genuinely has to do blocking work — a pandas transform, a library with no async version — the honest fix is to declare it def rather than async def, which makes FastAPI run it in a thread pool instead of on the loop. What you must not do is put blocking work inside async def.
Startup work has its own home. Use a lifespan context manager so it runs once when the app starts, not during module import:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient() # created once, shared by all requests
yield # ← app serves traffic here
await app.state.http.aclose() # shutdown
app = FastAPI(lifespan=lifespan)
One process, or several? This is where the odd bugs live
Production usually runs several worker processes, and often several container replicas. Each has its own memory. Anything stored in a module-level variable is therefore per-worker, and vanishes on restart.
# Before — "five exports per hour", enforced per process.
_recent: dict[int, list[float]] = {} # three workers ⇒ fifteen exports per hour
# After — one shared counter that survives restarts and scaling.
async def check_quota(user_id: int, db: AsyncSession) -> None:
hits = await db.scalar(
select(func.count()).select_from(ExportLog)
.where(ExportLog.user_id == user_id,
ExportLog.created_at > datetime.now(UTC) - timedelta(hours=1)))
if hits >= 5:
raise HTTPException(429, "Export limit reached; try again later.")
Local disk has the same problem. A nightly job that writes exports/responses.csv and an endpoint that serves it work perfectly on one machine and fail intermittently across two, because the file belongs to one instance and one lifetime — a restart, a redeploy or a scale-in destroys it. Durable artefacts go to Azure Blob Storage or an R2 bucket, and the endpoint hands out a link.
Containers, ports and localhost
A container is its own little machine. Inside it, localhost means the container, not your laptop and not a sibling container.
# Before — works locally, unhealthy in a container: the probe comes from outside
# and 127.0.0.1 refuses it. --reload also runs a pointless file watcher.
uvicorn api.server:app --host 127.0.0.1 --port 8000 --reload
# After — listens on all interfaces, on the port the platform tells you to use.
uvicorn api.server:app --host 0.0.0.0 --port ${PORT:-8000}
On Azure, an app setting is how a value reaches os.environ in the container. The platform injects it when the process starts, which gives you two facts worth remembering: the value is fixed for the life of the process, so a settings change needs a restart; and settings belong to a deployment slot, which is why staging can work while production fails.
az webapp config appsettings list -g iff-rg -n iff-research-api -o table
az webapp restart -g iff-rg -n iff-research-api # required for a change to take effect
CI is a third machine, and it is being honest
A CI runner is a fresh, empty computer. It has no .env, no local database, a different clock and operating system, and only the secrets the workflow declares. When something passes locally and fails there, the usual cause is a dependency your laptop had by accident, or a file that was never committed.
- run: pnpm install --frozen-lockfile # fails if the lockfile is stale
- run: pnpm build # the build machine: no runtime secrets here
env:
VITE_API_BASE_URL: https://api.iff.example # public by definition
The edge is a fourth shape
A Cloudflare Worker runs in a V8 isolate close to the user: it starts in milliseconds, has strict CPU limits per request, no local filesystem, no persistent in-process memory between requests, and only the APIs the runtime provides. That makes it excellent for auth checks, redirects, caching and a quick D1 lookup, and wrong for a twenty-minute pandas job. The right split is usually a Worker in front, a container behind.
What to check in your own code
- Build the front end, then grep
dist/for key-shaped strings and for everyVITE_name. Anything you find is public. - Grep for
requests.,time.sleep,open(and heavy library calls inside files containingasync def. - Grep for module-level mutable state used as a cache, counter or session store.
- Grep for
"/tmp",open(..., "w")andPath.write_in anything that runs on a server you do not personally restart. - Check the start command for
--reload, for127.0.0.1, and for a hard-coded port. - Compare your host’s configured settings against the env grep from the code, in both directions.
- Check whether a settings change you made was followed by a restart, and whether you changed the right slot.
An agent adds `const key = import.meta.env.VITE_AZURE_OPENAI_KEY` to a React component so the dashboard can summarise survey responses. What is the consequence?
Sources
- Adam Wiggins — The Twelve-Factor App — Processes website The stateless-process rule that explains why in-memory state breaks as soon as you run more than one worker.
- FastAPI — Deployments Concepts website Official explanation of the program-versus-process model, workers, restarts and startup work.
- Vite — Env Variables and Modes website Confirms that only VITE_-prefixed variables are exposed, and that they are statically replaced into the bundle at build time.
- Microsoft — Configure an App Service app website How app settings become environment variables in the running container, and why they are set per-slot.
- Cloudflare Workers runtime APIs website Shows which APIs exist in a Worker isolate, and by omission which Node.js facilities do not.