Move a Node.js API to AWS Lambda: A Practical Serverless Migration
Already have a Node.js API and wondering whether it can run without a traditional server? Learn how to move a small API endpoint to AWS Lambda, expose it through API Gateway, handle environment variables, structure the handler, and avoid common serverless mistakes.

Tools used: AWS Console, Node.js, npm, VS Code, AWS CLI, Git, GitHub
Prerequisites: Basic Node.js, JavaScript, REST APIs, HTTP methods, and environment variables.
Move a Node.js API to AWS Lambda: A Practical Serverless Migration
You already have a Node.js API.
It works.
You have routes.
You have controllers.
You have environment variables.
Maybe you are running it on a VPS, cloud VM, container, or traditional hosting service.
Then someone says:
"Why don't we make it serverless?"
That's where things get confusing.
Because moving an Express application to AWS Lambda isn't simply:
Upload Node.js code
↓
Lambda
↓
DoneLambda doesn't behave like a traditional server.
Your application is invoked when something calls it.
That means you need to rethink a few things:
Traditional API
Client
↓
Server
↓
Route
↓
Controller
↓
Response
Serverless API
Client
↓
API Gateway
↓
Lambda
↓
Handler
↓
ResponseThis guide walks through that migration using a small real-world API.
Instead of spending the entire article explaining what serverless means, we'll build something.
The application we're starting with
Imagine this Node.js endpoint:
GET /api/helloIt returns:
{
"message": "Hello from Karyvio API"
}A traditional Express implementation might look like:
import express from "express";
const app = express();
app.get("/api/hello", (req, res) => {
res.json({
message: "Hello from Karyvio API",
});
});
app.listen(3000, () => {
console.log("API running on port 3000");
});Run it:
node server.jsThen:
Browser
↓
http://localhost:3000/api/hello
↓
Express server
↓
JSON responseNow let's remove the always-running server.
The architecture after migration
The new version will look like this:
┌───────────────┐
│ Browser │
└───────┬───────┘
│
│ HTTPS
▼
┌───────────────┐
│ API Gateway │
└───────┬───────┘
│
│ event
▼
┌───────────────┐
│ AWS Lambda │
│ │
│ handler() │
└───────┬───────┘
│
▼
ResponseLambda runs the function when it is invoked.
You don't keep an Express server listening on port 3000.
First decision: should you actually migrate?
Before writing code, ask this:
| Situation | Lambda fit |
|---|---|
| Small HTTP API | Good candidate |
| Event-driven processing | Strong candidate |
| Background jobs | Strong candidate |
| S3-triggered processing | Strong candidate |
| Unpredictable traffic | Often useful |
| Long-running process | Usually not the first choice |
| Constant WebSocket connection | Requires different architecture |
| Stateful in-memory application | Needs redesign |
| Existing large Express monolith | Don't migrate everything at once |
The important lesson is:
Serverless is an architecture choice, not a checkbox.
You don't need to convert your entire backend just because one endpoint could run on Lambda.
A better migration strategy
Instead of this:
Entire application
↓
Rewrite everything
↓
Lambdause:
Existing API
│
├── /users
│
├── /jobs
│
├── /uploads
│
└── /health
│
▼
Pick one endpoint
│
▼
LambdaStart small.
Migrate one endpoint.
Measure it.
Then decide what comes next.
Step 1 — Create the Lambda project
Create a new directory:
mkdir karyvio-lambda-api
cd karyvio-lambda-apiInitialize Node.js:
npm init -yCreate:
karyvio-lambda-api/
├── package.json
└── index.mjsWe're using an ES module file:
index.mjsAWS documents both CommonJS and ES module handlers for Node.js Lambda functions and recommends ES modules when you want features such as top-level await.
Step 2 — Write the Lambda handler
Create:
index.mjsAdd:
export const handler = async (event) => {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: "Hello from Karyvio API",
}),
};
};That's your Lambda function.
There is no:
app.listen(3000);There is no:
express();There is simply a function:
handler(event)Lambda invokes the handler when the function receives an event. AWS documents event as the input passed to the function and context as information about the invocation and execution environment.
Step 3 — Understand the handler
This:
export const handler = async (event) => {
// logic
};is the Lambda equivalent of the entry point into your application logic.
For an HTTP request, you can think of:
HTTP Request
↓
API Gateway
↓
event
↓
handler(event)
↓
HTTP ResponseThe event contains information about the incoming request.
Depending on how API Gateway is configured, the exact event shape can differ.
That means you should avoid writing application logic that assumes every Lambda event looks identical.
Step 4 — Read the request
Let's say our API accepts:
GET /api/hello?name=SovitThe function can inspect the incoming event.
A simplified example:
export const handler = async (event) => {
const name =
event.queryStringParameters?.name || "Developer";
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
message: `Hello, ${name}!`,
}),
};
};Now:
/api/hello?name=Sovitcan produce:
{
"message": "Hello, Sovit!"
}The important part is not the exact property name.
The important part is understanding the pipeline:
Request
↓
API Gateway
↓
Lambda event
↓
Read event
↓
Application logic
↓
ResponseStep 5 — Add actual application logic
Let's make the endpoint slightly more realistic.
Imagine we want:
GET /api/jobsOur first version can use static data:
const jobs = [
{
id: 1,
title: "Frontend Developer",
company: "Example Tech",
},
{
id: 2,
title: "Backend Developer",
company: "Cloud Labs",
},
];
export const handler = async () => {
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
data: jobs,
}),
};
};The response becomes:
{
"success": true,
"data": [
{
"id": 1,
"title": "Frontend Developer",
"company": "Example Tech"
},
{
"id": 2,
"title": "Backend Developer",
"company": "Cloud Labs"
}
]
}Now we have something that actually resembles an API endpoint.
Step 6 — Move data outside the handler
This is an important Lambda pattern.
Instead of recreating clients and configuration every time the handler runs:
export const handler = async () => {
const client = createClient();
// ...
};initialize reusable objects outside the handler when appropriate:
const client = createClient();
export const handler = async (event) => {
// use client
};For example:
import { S3Client } from "@aws-sdk/client-s3";
const s3 = new S3Client({
region: process.env.AWS_REGION,
});
export const handler = async (event) => {
// use s3
};AWS's Node.js Lambda examples also initialize the S3 client outside the handler so it can be reused across invocations in the same execution environment.
This doesn't mean you should put request-specific state into global variables.
Think:
Reusable client
↓
Outside handler
Request data
↓
Inside handlerStep 7 — Add environment variables
Never hard-code values such as:
const databaseUrl =
"postgres://user:password@server/database";Use environment variables instead:
const databaseUrl =
process.env.DATABASE_URL;Your Lambda configuration can provide:
DATABASE_URL
AWS_REGION
JWT_SECRET
APP_ENVThen your code reads:
process.env.DATABASE_URLAWS documents environment variables as a standard way to provide configuration to Lambda functions.
But remember:
Environment variables are configuration, not automatically a secret-management solution.
For sensitive secrets, use an appropriate secrets-management approach rather than treating plaintext Lambda environment variables as your entire security strategy.
Step 8 — Create the API Gateway endpoint
Now the function needs an HTTP entry point.
The architecture becomes:
Client
│
│ GET /api/jobs
▼
API Gateway
│
│ Lambda event
▼
Lambda
│
│ response
▼
API Gateway
│
▼
ClientCreate an API Gateway HTTP API and connect:
GET /api/jobsto your Lambda function.
You don't need an Express server listening on a port.
API Gateway becomes the HTTP-facing component.
Step 9 — Test the Lambda function first
Before blaming API Gateway, test Lambda itself.
Use an event such as:
{
"queryStringParameters": {
"name": "Sovit"
}
}Your handler:
export const handler = async (event) => {
const name =
event.queryStringParameters?.name || "Developer";
return {
statusCode: 200,
body: JSON.stringify({
message: `Hello, ${name}!`,
}),
};
};Expected result:
{
"statusCode": 200,
"body": "{\"message\":\"Hello, Sovit!\"}"
}Testing the function independently helps isolate problems.
If Lambda works but the HTTP request fails:
Lambda works
↓
Investigate API GatewayIf Lambda itself fails:
API Gateway isn't the first problem
↓
Fix LambdaStep 10 — Add error handling
Don't allow every exception to become an unexplained failure.
A simple pattern:
export const handler = async (event) => {
try {
const name =
event.queryStringParameters?.name || "Developer";
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
message: `Hello, ${name}!`,
}),
};
} catch (error) {
console.error("Lambda error:", error);
return {
statusCode: 500,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
success: false,
message: "Internal server error",
}),
};
}
};Notice:
console.error(...)is useful for logs.
But don't return:
{
"error": "DATABASE_PASSWORD_INVALID"
}to the client.
Internal errors should remain internal.
Step 11 — Add a database carefully
This is where a lot of first-time serverless applications become problematic.
Traditional architecture:
Node.js Server
│
│ persistent process
▼
DatabaseServerless:
Lambda
│
├── invocation
│
├── invocation
│
├── invocation
│
└── invocation
│
▼
DatabaseIf every invocation creates a new database connection without considering connection reuse and concurrency, your database can become the bottleneck.
A safer pattern is to initialize a reusable client outside the handler when the library and database architecture support it:
const db = createDatabaseClient();
export const handler = async (event) => {
const jobs = await db.jobs.findMany();
return {
statusCode: 200,
body: JSON.stringify({
data: jobs,
}),
};
};But the exact strategy depends on your database driver and deployment architecture.
Serverless does not remove database connection problems.
It changes the shape of the problem.
The mistake: treating Lambda like a tiny VPS
This is wrong thinking:
"I have a server,
but AWS calls it for me."A better mental model is:
"I have a function
that executes because something happened."That distinction changes how you design the application.
What happens during an invocation?
Conceptually:
Request arrives
│
▼
Lambda execution environment
│
├── initialization
│
├── handler
│
└── responseSometimes the existing execution environment can be reused.
Sometimes AWS needs to initialize a new environment.
This is one reason you should avoid assuming that memory, global state, or local files behave like permanent server storage.
Don't store application state in memory
This looks tempting:
const users = [];
export const handler = async (event) => {
users.push(event.user);
return {
statusCode: 200,
body: JSON.stringify(users),
};
};Don't use Lambda memory as your application's database.
You cannot rely on:
Invocation 1
↓
users = [...]
↓
Invocation 2
↓
same users guaranteed?No.
If the application needs persistent state, use persistent storage.
For example:
Lambda
│
├── DynamoDB
├── RDS
├── S3
└── another persistent serviceWhat about files?
The same principle applies.
Don't design your application around:
Lambda local disk
=
permanent file storageIf your application needs durable files:
Lambda
│
▼
Amazon S3This is especially relevant to the previous Karyvio article about presigned S3 uploads.
You can combine the two patterns:
Browser
│
│ upload directly
▼
S3
│
│ event
▼
Lambda
│
│ process metadata
▼
DatabaseNow Lambda becomes the event-driven processing layer rather than the file-transfer layer.
A powerful combination: S3 + Lambda
Imagine a user uploads:
resume.pdfThe browser uploads it directly to S3.
Then S3 triggers Lambda.
Browser
│
▼
S3
│
│ object created
▼
Lambda
│
├── validate
├── extract metadata
├── update database
└── start processingThis is much more interesting than simply replacing Express with Lambda.
You are building an event-driven architecture.
AWS documents Lambda integrations with services such as S3, SQS, EventBridge, and API Gateway.
Your first useful Lambda API
Let's turn our example into something closer to a production endpoint.
export const handler = async (event) => {
try {
const method = event.requestContext?.http?.method;
if (method !== "GET") {
return {
statusCode: 405,
body: JSON.stringify({
message: "Method not allowed",
}),
};
}
const jobs = [
{
id: "job_101",
title: "Frontend Developer",
company: "Example Tech",
},
{
id: "job_102",
title: "Cloud Engineer",
company: "Cloud Labs",
},
];
return {
statusCode: 200,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
success: true,
data: jobs,
}),
};
} catch (error) {
console.error(error);
return {
statusCode: 500,
body: JSON.stringify({
success: false,
message: "Internal server error",
}),
};
}
};This is intentionally simple.
The goal is to understand the execution model before adding databases, authentication, queues, caching, and other infrastructure.
Authentication changes too
In a traditional Express API you might have:
app.use(authMiddleware);In Lambda, authentication can happen at different layers depending on your architecture.
For example:
Client
↓
API Gateway
↓
Authorizer
↓
Lambdaor:
Client
↓
API Gateway
↓
Lambda
↓
application authenticationThe correct approach depends on your requirements.
The important thing is not to assume that migrating to Lambda automatically gives your API authentication.
It doesn't.
You still need an identity and authorization design.
Logging becomes more important
A traditional server might write logs to:
server.logA Lambda application needs centralized observability.
At minimum, log useful information such as:
console.info("Processing job", {
jobId,
});and errors:
console.error("Job processing failed", {
jobId,
error: error.message,
});Don't log secrets.
Avoid:
console.log({
authorizationHeader,
password,
databaseUrl,
});Logs can become sensitive data very quickly.
Watch your function size
Your Lambda deployment should not become a giant application bundle containing:
Everything
+
Every dependency
+
Unused libraries
+
Old codeKeep the function focused.
For example:
functions/
├── getJobs/
├── createJob/
├── processUpload/
└── sendNotification/Or group functions according to your application's architecture.
The right granularity depends on the project.
Don't create 500 functions simply because you can.
Dependencies matter
A function that only needs:
fetch()doesn't necessarily need:
200 npm packagesLarge dependency trees can increase deployment size and initialization work.
Keep dependencies intentional.
For AWS SDK usage, current Node.js Lambda runtimes include a version of AWS SDK for JavaScript v3, although the included version depends on the runtime and Region. If you need a specific SDK version, package that dependency with your function instead of assuming the runtime's included version is exactly what you need.
Lambda Layers aren't a magic dependency folder
Lambda Layers can package reusable dependencies and code.
Conceptually:
Layer
│
├── shared library
├── dependency
└── reusable code
│
├── Lambda A
├── Lambda B
└── Lambda CAWS documents Layers as a way to package reusable code and dependencies for multiple functions.
But don't automatically put everything into a Layer.
Sometimes bundling dependencies directly with each function makes deployment simpler.
Choose based on actual reuse and deployment needs.
Deployment options
A Lambda function can be deployed in different ways.
For Node.js, AWS documents deployment using:
ZIP packageor:
Container imageA simple project can start with a ZIP deployment.
Your package might look like:
project/
├── index.mjs
├── package.json
├── package-lock.json
└── node_modules/Then package the required files into a deployment archive.
AWS also supports container-image deployment when that model fits your application.
The migration path I'd actually recommend
Don't start with:
Entire Express application
↓
AWS LambdaUse:
Phase 1
One simple endpoint
↓
Phase 2
API Gateway
↓
Phase 3
Environment variables
↓
Phase 4
Database access
↓
Phase 5
Authentication
↓
Phase 6
Logging + monitoring
↓
Phase 7
Event-driven processingThis lets you learn the architecture without creating a giant migration project.
Before and after
Traditional
┌──────────────┐
│ Node.js │
Client ────────────►│ Server │
│ │
│ Express │
│ Routes │
│ Controllers │
└──────┬───────┘
│
▼
DatabaseServerless
┌──────────────┐
│ API Gateway │
Client ────────────►│ │
└──────┬───────┘
│
▼
┌──────────────┐
│ Lambda │
│ │
│ Handler │
└──────┬───────┘
│
▼
DatabaseNeither architecture is automatically better.
The useful question is:
Which architecture fits this workload?
When Lambda starts making sense
Lambda becomes especially interesting when your application has work such as:
HTTP request
↓
Lambdaor:
S3 upload
↓
Lambdaor:
Queue message
↓
Lambdaor:
Scheduled event
↓
LambdaThe common theme is:
something happens → your function responds.
That event-driven model is the real value.
When you should probably keep a traditional server
Don't force Lambda into every application.
A traditional server or container may be easier when you have:
- long-running processes
- persistent connections
- specialized server software
- predictable high utilization
- complex in-memory state
- workloads that don't fit short event-driven execution
- an existing architecture that would become unnecessarily complicated after migration
Serverless isn't automatically cheaper, faster, or simpler.
It solves a particular class of infrastructure problems.
The 5 mistakes to avoid
1. Treating Lambda like a VPS
Don't assume the function is a permanent server.
2. Storing important state in memory
Use persistent storage.
3. Creating expensive clients repeatedly
Reuse clients where appropriate.
4. Putting secrets directly in source code
Use secure configuration and secret-management practices.
5. Migrating the entire application at once
Start with one endpoint.
A small production checklist
Before calling your migration complete:
[ ] Handler works independently
[ ] API Gateway route works
[ ] Environment variables configured
[ ] IAM permissions are minimal
[ ] Authentication is implemented
[ ] Errors are handled
[ ] Logs don't contain secrets
[ ] Database connections are designed for concurrency
[ ] Persistent state is stored outside Lambda memory
[ ] Deployment process is repeatable
[ ] Monitoring is configured
[ ] Timeouts are intentional
[ ] Function dependencies are controlled
[ ] CORS is configured if the browser calls the APIDon't treat the checklist as the architecture.
Use it as the final sanity check.
The bigger lesson
Moving a Node.js API to Lambda isn't really about replacing:
Expresswith:
LambdaIt's about changing the way you think about backend execution.
Traditional thinking:
Keep server running
↓
Wait for requests
↓
Handle requestServerless thinking:
Something happened
↓
Invoke function
↓
Do one job
↓
Return resultOnce you understand that difference, other AWS services start making more sense.
S3 can trigger Lambda.
API Gateway can invoke Lambda.
Queues can invoke Lambda.
Scheduled events can invoke Lambda.
Your application becomes a collection of focused pieces connected by events.
And that is where serverless becomes much more interesting than simply "running Node.js without managing a server."
Final architecture to remember
┌───────────────┐
│ Client │
└───────┬───────┘
│
▼
┌───────────────┐
│ API Gateway │
└───────┬───────┘
│
▼
┌───────────────┐
│ AWS Lambda │
│ │
│ Focused logic │
└───────┬───────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
Database S3 QueueDon't move your entire backend to Lambda because serverless sounds modern. Move the workloads that actually benefit from event-driven execution.







Comments (0)
Be the first to share your thoughts.