🪴 GoDeep
Search
← Shipping Software

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.

MachineWhat it doesWhat it has
Your laptopRuns dev servers with hot reloadYour .env, your local database, everything you ever installed
The CI runnerInstalls, tests, builds artefacts, then is destroyedNothing except what the workflow installs and the secrets you gave it
The server processHandles live requestsThe real secrets, no browser, possibly several copies of itself
The static host or CDNSends built files to browsersNo ability to compute anything
The user’s browserExecutes every line of your React codeThe 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 every VITE_ name. Anything you find is public.
  • Grep for requests., time.sleep, open( and heavy library calls inside files containing async def.
  • Grep for module-level mutable state used as a cache, counter or session store.
  • Grep for "/tmp", open(..., "w") and Path.write_ in anything that runs on a server you do not personally restart.
  • Check the start command for --reload, for 127.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.

Sources