
Shipping a laptop-first FastAPI and Strands Agents app to AWS without rewriting it
AirGuard was built to run on a laptop with a local model and a local database. This is how we moved it to AWS Lambda, API Gateway, Amazon Bedrock and Amplify with no cloud-only fork of the code, and what pushed back on the way.
AirGuard ran nicely on a laptop. Local Postgres through Supabase, XGBoost model files on disk, and a Strands agent talking to a 1.7-billion-parameter model through Ollama. That's where a lot of hackathon projects stay.
On the last morning of the hackathon my job was to get it onto AWS, with one rule: no "cloud version" of the code. Same FastAPI app, same agent, same frontend, just deployed. This post covers how that went, what pushed back, and what I'd tell anyone putting a Python API on Lambda this weekend.
Quick context: AirGuard is a commute planner for Delhi. It forecasts PM2.5 every 200 metres along your route and tells you which road and which departure time mean breathing less smoke. Ayush wrote about the forecasting and the agent here: [link]
It's live at https://main.d3o4840p1nk3yq.amplifyapp.com , and the API's interactive docs are at https://xizab63jhj.execute-api.ap-northeast-2.amazonaws.com/docs
The shape of it
text
Browser└─ AWS Amplify (Next.js frontend)└─ /api/* → Amazon API Gateway└─ AWS Lambda (container image in Amazon ECR)FastAPI + Uvicorn, through the Lambda Web Adapter├─ Amazon Bedrock (Amazon Nova 2 Lite) for the assistant└─ Postgres for stations, forecasts and saved trips
Apart from the database, nothing in that diagram is a server we keep running.
Step 1: FastAPI on Lambda, without touching FastAPI
The usual way to put FastAPI on Lambda is Mangum: you wrap the app in a handler that translates Lambda events into ASGI. It works, but now your app has a Lambda-shaped entry point, and "running locally" and "running on Lambda" quietly become two different things.
The AWS Lambda Web Adapter goes the other way. It's an open-source Lambda extension that lives inside the container next to your normal web server. Lambda invokes the function, the adapter turns the event into a plain HTTP request to localhost:8000, and Uvicorn answers like it would anywhere else. The whole integration is one COPY line. Here's our Dockerfile, trimmed a little:
dockerfile
FROM public.ecr.aws/docker/library/python:3.12-slim# The adapter: turns Lambda invocations into HTTP requests to our serverCOPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.8.4 /lambda-adapter /opt/extensions/lambda-adapterENV PORT=8000 PYTHONUNBUFFERED=1WORKDIR /appCOPY backend/requirements.lock .RUN pip install --no-cache-dir -r requirements.lockRUN pip install --no-cache-dir boto3COPY backend/ ./backend/RUN pip install --no-cache-dir --no-deps -e ./backendCMD ["uvicorn", "airguard.api.main:app", "--host", "0.0.0.0", "--port", "8000"]
Because the adapter only does anything inside Lambda, the exact same image runs locally with
docker run -p 8000:8000 --env-file .env.local <image> , and you can hit /health before you push anything. A nice side effect: FastAPI's /docs page works through API Gateway exactly as it does on a laptop.A few things made this easy, and most of them come down to how the backend was already written:
- A pinned lock file.
requirements.lockhas exact versions of everything. pip's resolver had already stalled on this dependency set once on a laptop, and you don't want to find that out halfway through a container build.
- A stateless API. Settings come from environment variables through pydantic-settings, and trip analyses are saved in Postgres, not in memory, so any Lambda instance can answer any request.
- Models inside the image. The XGBoost files are small, so they ship in the image instead of being downloaded from S3 on every cold start. The trade-off is that a new model means a new image.
- The region came free. Lambda sets
AWS_REGIONfor you, and our settings class already read it, so the Bedrock client picked up the right region without any extra config.
The image goes to Amazon ECR, and the Lambda function runs from that image.
What pushed back: size. pandas, NumPy and XGBoost make a big image, and the first request after the function has been idle is noticeably slower than the rest. For a demo the answer is to warm it up first. For real use it'd be provisioned concurrency during commute hours, or a slimmer image.
Step 2: API Gateway, and the 30-second budget
Amazon API Gateway gave us an HTTPS endpoint in front of the function. Its integration timeout is about 30 seconds. That's generous for a normal API, and tight for an AI agent that might call several tools before it answers.
What keeps AirGuard well inside it is a design choice Ayush made early. The expensive work (every route × every departure × every 200-metre segment) happens once, when you plan a trip, and the result is saved. When you ask the assistant a question, it mostly reads that saved analysis instead of recomputing anything. The toolsets are small, so the model doesn't wander. And if the model call fails, the API returns a plain explanation built from the saved analysis instead of an error.
Step 3: Swapping a laptop model for Amazon Bedrock
On paper, this was the easy part. Ayush built the agent so the model is a setting, so on Lambda the whole switch is two environment variables:
Bash
LLM_BACKEND=bedrockBEDROCK_MODEL_ID=amazon.nova-2-lite-v1:0
The function's execution role also needs permission to call the model. Strands streams through Bedrock's Converse API, so the role needs both actions:
JSON
{"Effect": "Allow","Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],"Resource": "*"}
(Scope
Resource down to the model and inference profile ARNs once it works.)"On paper", because the first time we tested the assistant end to end on the deployed API, it answered from its fallback. The response told us why:
model error: ValidationException . The app didn't break. Users still got a correct, verified explanation of their trip, which is exactly what the fallback is for. But it wasn't the assistant we wanted people to see.If you get a ValidationException from a newer Bedrock model, check the model ID before anything else. Models like Nova 2 Lite are usually called through a cross-region inference profile, so the ID you pass needs the profile's prefix (
global. , or a geography such as apac. ), and your IAM policy has to allow the inference profile as well as the model. The bare ID you see in the model catalogue isn't always one you can call on demand. CloudWatch has the full error text, and it usually says which of these it is.Step 4: Amplify and a monorepo
The frontend is Next.js 16 in
/frontend , and the backend is in /backend , in the same repository. My first amplify.yml used the single-app format with a cd frontend in the build commands. That didn't work. Amplify wants a monorepo described with applications and appRoot :YAML
version: 1applications:- appRoot: frontendfrontend:phases:preBuild:commands:- npm cibuild:commands:- npm run buildartifacts:baseDirectory: .nextfiles:- '**/*'cache:paths:- node_modules/**/*- .next/cache/**/*
Note that the paths are now relative to
appRoot , not the repo root. The broken version and the fix went in fourteen minutes apart. After that, every push to main redeployed the site.The last piece is how the frontend talks to the backend. In production the browser only ever calls
/api/... on its own origin, and Next.js forwards it to API Gateway:ts
async rewrites() {const backendUrl = process.env.BACKEND_API_URL || "https://<api-id>.execute-api.<region>.amazonaws.com";return [{ source: "/api/:path*", destination: `${backendUrl}/:path*` }];},
You can see it working: https://main.d3o4840p1nk3yq.amplifyapp.com/api/health is served by the Amplify site, but the answer comes from Lambda. That gives us no CORS setup on the deployed site, and an API address that changes with one environment variable instead of a rebuild of the client code. In local development the same frontend points straight at
localhost:8000 .What isn't on AWS yet
Being honest about the gaps:
- The hourly data job isn't scheduled on AWS yet, so the deployed forecast only refreshes when we run it. The plan is a second Lambda function from the same image with a different command, triggered every hour by Amazon EventBridge Scheduler.
- Model files should move to S3, so retraining doesn't mean rebuilding the image.
- API keys and the database URL should live in AWS Secrets Manager.
- The function should stay warm during commute hours, when people actually use it.
If you're deploying a FastAPI app to Lambda this weekend
- Use the Lambda Web Adapter and keep your app a normal web server. Run the exact image locally before you push it.
- Pin your dependencies before you write the Dockerfile.
- Keep the API stateless. Anything a second request needs belongs in the database.
- Design around API Gateway's 30 seconds: precompute the heavy work, keep agent toolsets small, and have a fallback answer.
- For Bedrock, check the exact model ID, its regions and its inference profiles before writing code, and give your IAM role access to both the profile and the model.
- For an Amplify monorepo, use
applicationsandappRootfrom the start.
The part I liked most: the app running on AWS is the same app that runs on a laptop. Lambda never leaked into the application code.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article