Who this is for: You've written some code before and you're curious about AWS, but you've never touched serverless. By the end of this guide you'll have a real, deployed API with zero servers to manage.
What is "serverless" really?
"Serverless" doesn't mean there are no servers. It means you don't have to think about them.
Traditionally, if you wanted your code to run on the internet, you had to rent a server, an EC2 instance on AWS, for example, keep it running 24/7, patch it, monitor it, and pay for it whether anyone was using your app or not. That's a lot of overhead.
With serverless, you just upload your code. AWS handles provisioning, scaling, and maintenance. Your code runs only when it's triggered, when someone makes an API call, uploads a file, or inserts a record. When there's nothing to do, nothing runs, and nothing charges you.
| Traditional server | Serverless |
|---|---|
| Always-on, always billed | ✅ Pay only when code runs |
| You manage OS patches | ✅ AWS manages all of that |
| Manual scaling | ✅ Auto-scales to thousands of requests |
| Complex setup | ✅ Deploy in minutes |
| — | ⚠️ Cold starts (small latency on first run) |
💡 The AWS Free Tier is generous. Lambda gives you 1 million free requests per month and 400,000 GB-seconds of compute, forever, not just for 12 months. The project we're building today will cost you essentially nothing.
Our project: a URL shortener
We'll build a minimal URL shortener. You send it a long URL, it gives you back a short code. You visit that code, it redirects you to the original URL.
Simple enough to build in one sitting, but real enough to teach you how the pieces actually connect.
Services we'll use:
- 🔗 API Gateway — receives HTTP requests from the internet
- ⚡ AWS Lambda — runs your business logic
- 🗄 DynamoDB — stores the short code → URL mapping
The architecture
Here's how the three services talk to each other. Read top to bottom, that's the path a request takes.
User's browser or curl
↓ POST /shorten { url: "https://example.com/very/long/path" }
[ API Gateway ]
→ validates the request, forwards to Lambda
→ you never expose Lambda directly to the internet
↓ triggers
[ Lambda function — Node.js 20 ]
→ generates a short code (e.g. "abc123")
→ writes { code → url } to DynamoDB
→ returns the short URL to the user
↓ reads / writes
[ DynamoDB table — "urls" ]
partition key: shortCode (String)
attribute: originalUrl (String)
TTL: expiresAt (auto-deletes old records = saves money)
💡 Why API Gateway in front of Lambda? It lets you add rate limiting, authentication, and HTTPS without touching your function code. Lambda stays focused on business logic, the gateway handles the door.
Step-by-step build
Step 1 — Create the DynamoDB table
Go to the AWS Console → DynamoDB → Create table.
-
Table name:
urls -
Partition key:
shortCode(String) - Capacity mode: On-demand (you pay per read/write, not per hour. Perfect for low or unpredictable traffic)
Leave everything else as default and hit Create.
Step 2 — Create an IAM role for Lambda
Go to IAM → Roles → Create role.
- Trusted entity: Lambda
-
Attach managed policy:
AWSLambdaBasicExecutionRole(gives Lambda permission to write logs to CloudWatch) - Add an inline policy for DynamoDB (see below, don't skip this step)
The IAM inline policy (least privilege):
Only give Lambda permission to do exactly what it needs on exactly your table, nothing else.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"dynamodb:PutItem",
"dynamodb:GetItem"
],
"Resource": "arn:aws:dynamodb:us-east-1:YOUR_ACCOUNT_ID:table/urls"
}]
}
⚠️ Replace
YOUR_ACCOUNT_IDwith your actual 12-digit AWS account ID, andus-east-1with whichever region you created your table in. These must match exactly.
Step 3 — Create the Lambda function
Go to Lambda → Create function.
- Runtime: Node.js 20.x
- Execution role: Use the IAM role you created in Step 2
- Memory: 128 MB
- Timeout: 5 seconds
Paste the following code into the inline editor:
import { DynamoDBClient, PutItemCommand, GetItemCommand } from "@aws-sdk/client-dynamodb";
// Reuse the client across invocations — keeps cold starts short
const db = new DynamoDBClient({});
const TABLE = "urls";
export const handler = async (event) => {
const { routeKey, body, pathParameters } = event;
// POST /shorten — create a new short URL
if (routeKey === "POST /shorten") {
const { url } = JSON.parse(body);
const code = Math.random().toString(36).slice(2, 8);
// TTL: auto-delete this record after 90 days (cost saving)
const ttl = Math.floor(Date.now() / 1000) + 90 * 86400;
await db.send(new PutItemCommand({
TableName: TABLE,
Item: {
shortCode: { S: code },
originalUrl: { S: url },
expiresAt: { N: String(ttl) }
}
}));
return { statusCode: 201, body: JSON.stringify({ short: `/${code}` }) };
}
// GET /{code} — resolve a short URL
if (routeKey === "GET /{code}") {
const result = await db.send(new GetItemCommand({
TableName: TABLE,
Key: { shortCode: { S: pathParameters.code } }
}));
if (!result.Item) return { statusCode: 404, body: "Not found" };
return {
statusCode: 301,
headers: { "Location": result.Item.originalUrl.S }
};
}
};
Step 4 — Create the API Gateway endpoint
Go to API Gateway → Create API → HTTP API (simpler and ~70% cheaper than REST API).
-
Add two routes:
POST /shortenGET /{code}
- Integration: Point both routes to your Lambda function
-
Deploy to a stage called
prod
Copy the URL it gives you. That's your live API endpoint.
Step 5 — Test it
# Shorten a URL
curl -X POST https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/shorten \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/a/very/long/path"}'
# Response: {"short": "/abc123"}
# Resolve it (your browser will follow the redirect automatically)
curl -L https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com/abc123
If you see a redirect to your original URL, you just shipped a serverless app. No server. No Docker. No SSH session.
Security best practices
Security in serverless is different from traditional server security, there's no OS to harden, no SSH port to close. The risks shift, so the defences shift too.
1. Principle of least privilege (IAM)
Your Lambda function's IAM role is its identity. Only give it permission to do exactly what it needs. In the policy above, we granted only PutItem and GetItem on one specific table, not DeleteItem, not Scan, not access to any other table or service.
The rule: if your function doesn't need a permission, don't give it that permission. An attacker who compromises your function can only do what the function is allowed to do.
2. Never hardcode secrets in your code
If your function needs an API key or a password, don't paste it in the source code. Use AWS Secrets Manager or Parameter Store. Your function fetches the secret at runtime using its IAM role, no secrets in code, no secrets in environment variables that show up in console screenshots.
import { SSMClient, GetParameterCommand } from "@aws-sdk/client-ssm";
const ssm = new SSMClient({});
const param = await ssm.send(new GetParameterCommand({
Name: "/myapp/api-key",
WithDecryption: true // decrypts KMS-encrypted values automatically
}));
const apiKey = param.Parameter.Value;
3. Validate all input
Your Lambda is reachable from the internet via API Gateway. Anyone can send it anything. Always validate the shape and content of incoming data before using it, check that the URL is actually a URL, that required fields exist, that strings aren't suspiciously long.
4. Enable CloudWatch logs and set a retention period
Lambda logs every invocation to CloudWatch automatically. But by default, logs are kept forever (and charged per GB stored). Set a retention period: 7 or 30 days is usually enough.
In the AWS Console: CloudWatch → Log groups → your function's log group → Actions → Edit retention setting.
Keeping costs near zero
Here's what this project actually costs at light traffic (~10,000 requests/month):
| Service | What you're billed for | Estimated cost |
|---|---|---|
| Lambda | 10k requests × 128 MB × ~100ms | $0.00 (free tier) |
| API Gateway (HTTP API) | $1.00 per million requests | ~$0.01 |
| DynamoDB (on-demand) | ~$0.00025 per read/write unit | ~$0.01 |
| Total | ~$0.02 / month |
Cost-saving choices we made and why
HTTP API over REST API in API Gateway
HTTP API costs 70% less than the older REST API and is more than sufficient for most use cases. REST API has additional features (request transformation, WAF integration) but you probably don't need them yet.
DynamoDB on-demand capacity
With provisioned capacity, you pay for throughput 24/7 even when idle. On-demand billing means you pay per actual read and write, perfect for apps with unpredictable or low traffic.
DynamoDB TTL for automatic cleanup
We set an expiresAt timestamp in the Lambda function. DynamoDB reads this attribute and automatically deletes old records for free, no scheduled jobs, no manual cleanup, no growing table size eating into your storage bill.
128 MB Lambda memory
Lambda charges for memory × duration. 128 MB is the minimum, and for a simple DynamoDB read/write, it's more than enough. Only increase memory if your function is slow; more memory also means more CPU allocated to your function.
What to build next
You now have a working, secured, cost-optimised serverless app. Here are natural next steps, each one introduces a new piece of the AWS serverless ecosystem.
Add a frontend with S3 + CloudFront
Upload a simple HTML form to an S3 bucket and serve it through CloudFront (AWS's CDN). Now your users have a UI instead of a curl command. S3 static hosting costs cents per GB stored.
Add authentication with Cognito
Require users to sign in before shortening URLs. API Gateway can validate Cognito JWTs automatically, your Lambda function never sees unauthenticated requests.
Move to infrastructure-as-code with AWS CDK or SAM
Right now you've clicked through the console. The next level is writing your infrastructure as code so you can deploy it repeatably, version it in git, and tear everything down cleanly when you're done experimenting.
Wrapping up
Here's what we built, and why each decision was made:
- API Gateway keeps Lambda off the public internet and gives you rate limiting and HTTPS for free
- Lambda runs only when triggered, no idle billing, no patching, no servers
- DynamoDB on-demand scales with your traffic and charges nothing when nobody's using your app
- Least-privilege IAM means a compromised function can only do what the function is supposed to do
- TTL on DynamoDB records means old data cleans itself up automatically
That's the serverless model in its purest form. No servers. No patching. No idle billing. One Lambda function, two AWS services, and a handful of IAM rules.
Found this helpful? Follow along. The next post covers adding a frontend with S3 and CloudFront.
Top comments (0)