Deploy a URL Shortener to AWS Lambda
Build a serverless URL shortener API with CI/CD using GitHub Actions.
Introduction
30 Second Summary
Every time you share a link on social media or in a message, you've probably used a short URL that replaces a long, messy address with something clean and memorable. Behind each one is an API that maps short codes to destinations and handles the redirect in milliseconds.
In this project, you will build and deploy a URL shortener API to AWS Lambda. You'll create the API with Express.js, deploy it as a serverless function, write automated tests, and set up a CI/CD pipeline with GitHub Actions that ships every push to production.
What You'll Build
You'll paste a long URL into your live API endpoint, get back a short code, and watch anyone who hits that code get instantly redirected to the original destination while click analytics tick up in real time.
By the end of this project, you'll have:
- A live REST API on AWS Lambda where you shorten any URL and get back a unique short code, then hit that code and watch it redirect to the original destination.
- A click analytics endpoint that shows total clicks and timestamps for any short link you've created.
- An automated pipeline that runs your test suite and deploys to AWS on every push to main, so you ship with confidence without manual steps.
- Secret Mission: Add DynamoDB persistence so your short links survive Lambda cold starts and never disappear.
Are there any prerequisites?
You should be comfortable with Express.js and Node.js basics (routes, middleware, JSON responses). You'll also need an AWS account with free tier credits, a GitHub account, and basic Git knowledge (commit, push, pull).
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Tools and Project
You have a clear picture of what you're building. Before writing any API code, you need two command-line tools configured: the Serverless Framework CLI (which packages and deploys your code to AWS Lambda) and AWS credentials that give the CLI permission to create resources in your account.
In this step, you'll install both tools, verify they work, and scaffold the project directory with all its dependencies.
In this step, get ready to:
- Install and authenticate with the Serverless Framework CLI.
- Configure your AWS credentials.
- Initialize the project and install dependencies.
Install Serverless Framework globally and authenticate
The Serverless Framework is a CLI tool that turns a simple config file into a full AWS deployment. It handles creating your Lambda function, API Gateway, and all the wiring between them.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Install the Serverless Framework CLI globally by running this command:
npm i serverless -g
What does this command do?
The -g flag installs the package globally on your machine, making the serverless command available from any directory in your terminal.
- Confirm the installation worked by running:
serverless --version
✔️ I see a version number
The Serverless Framework is installed and ready. Move on to the next action below.
ⓧ Command not found
Your system cannot find the serverless command. This usually means the global npm directory is not in your system PATH.
- Try closing your terminal completely and reopening it, then run serverless --version again.
- If it still fails, run npm config get prefix to see where npm installs global packages, and add that path's bin folder to your system PATH.
ⓧ Permission error
Your system blocked the global install due to folder permissions.
- On macOS/Linux, re-run with sudo npm i serverless -g and enter your password.
- On Windows, close PowerShell and reopen it as Administrator (right-click, Run as administrator), then retry the install command.
Now you need to authenticate. The Serverless Framework requires a free account to deploy (free for individuals and organizations under $2M annual revenue).
- Log in to your Serverless Framework account by running:
serverless login
What happens when I run this?
This command opens your default browser to the Serverless Framework Dashboard. If you don't have an account yet, you can create one from the same page.
Once you authenticate in the browser, your terminal automatically receives a credential that persists on your machine. You won't need to log in again from this computer.
Browser didn't open?
If the browser doesn't open automatically, the CLI prints a URL in the terminal. Copy that URL and paste it into your browser manually to complete authentication.
Help me troubleshoot serverless login
Configure AWS CLI credentials
The Serverless Framework needs AWS credentials to create resources in your account. You provide these through the AWS CLI, which stores them locally on your machine.
- Check if the AWS CLI is installed by running:
aws --version
✔️ I see a version number
Your AWS CLI is ready. Move on to the configuration step below.
ⓧ Command not found
You need to install the AWS CLI first.
macOS
- Install the AWS CLI by running:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
- After installation completes, close and reopen your terminal.
- Verify it worked by running aws --version again.
Windows
- Download and run the official MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
- Follow the installer prompts, accepting the defaults.
- After installation completes, close and reopen PowerShell.
- Verify it worked by running aws --version again.
Now configure your AWS credentials. You need an Access Key ID and Secret Access Key from your AWS account.
Where do I find my AWS access keys?
- Sign in to the AWS Console at https://console.aws.amazon.com.
- Click your account name in the top-right corner.
- Select Security credentials.
- Scroll to Access keys and click Create access key.
- Copy both the Access Key ID and the Secret Access Key. Store them somewhere safe. You will not be able to see the secret key again after this page.
- Configure the AWS CLI with your credentials by running:
aws configure
What will this command ask me?
The command prompts you for four values, one at a time:
- AWS Access Key ID — paste your Access Key ID.
- AWS Secret Access Key — paste your Secret Access Key.
- Default region name — type us-east-1.
- Default output format — press Enter to accept the default (json).
Before you run the next command, what do you expect to see if your credentials are configured correctly?
- Verify your credentials work by running:
aws sts get-caller-identity
You should see a JSON response containing your AWS Account ID, UserId, and ARN. This confirms your credentials are valid and connected to your account.
Seeing 'Unable to locate credentials'?
This means aws configure didn't save your keys properly. Run aws configure again and make sure you paste the full key values without extra spaces.
Help me fix my AWS CLI credentials
Initialize the project
With your tools authenticated, you can scaffold the project folder and install the packages your URL shortener needs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir url-shortener-api
cd url-shortener-api
- Initialize a package.json file by running:
npm init -y
You should see output showing the generated package.json contents printed in your terminal.
- Install the production dependencies by running:
npm install express@4.21.2 serverless-http@4.0.0 helmet@8.1.0 cors@2.8.5 nanoid@3.3.11
What are these packages?
- express — the web framework for handling HTTP routes.
- serverless-http — wraps your Express app so it runs inside a Lambda function.
- helmet — adds security headers to every response.
- cors — enables cross-origin requests so browsers can call your API.
- nanoid — generates short, unique IDs for your shortened URLs.
- Install the development dependencies by running:
npm install -D serverless-offline@14.7.4 jest@30.0.4 supertest@7.1.0
What are these dev packages?
- serverless-offline — emulates Lambda locally so you can test without deploying.
- jest — the test runner for your automated tests.
- supertest — lets you send HTTP requests to your Express app inside tests.
Now you need to update package.json with the correct project metadata and npm scripts. These scripts give you shortcuts for running, testing, and deploying your API.
- Open package.json in your code editor.
- Find the "description", "main", and "scripts" fields near the top of the file.
- Replace those three fields with the following:
"description": "Serverless URL shortener API deployed to AWS Lambda",
"main": "src/handler.js",
"scripts": {
"start": "node src/local.js",
"dev": "npx serverless offline",
"test": "jest --detectOpenHandles",
"deploy": "npx serverless deploy"
},
What do these scripts do?
- start — runs the app as a plain Express server for quick local testing.
- dev — runs the app through the serverless-offline plugin, emulating the Lambda environment locally.
- test — runs your automated test suite with Jest.
- deploy — deploys your API to AWS Lambda using the Serverless Framework.
- Save package.json.
- Verify your project is set up correctly by running:
cat package.json
You should see your dependencies, devDependencies, and the four scripts you just added all present in the output.
Missing dependencies in package.json?
If you don't see the dependencies or devDependencies sections, the install commands may have failed. Re-run both npm install commands from above and check for error messages about network or permission issues.
Help me fix my package.json setup
✔️ Awesome, I've got everything!
Great. Double check that you've saved your file.
ⓧ I'd like to double check the full code
Here is the complete package.json you should have at this point:
{
"name": "url-shortener-api",
"version": "1.0.0",
"description": "Serverless URL shortener API deployed to AWS Lambda",
"main": "src/handler.js",
"scripts": {
"start": "node src/local.js",
"dev": "npx serverless offline",
"test": "jest --detectOpenHandles",
"deploy": "npx serverless deploy"
},
"dependencies": {
"cors": "2.8.5",
"express": "4.21.2",
"helmet": "8.1.0",
"nanoid": "3.3.11",
"serverless-http": "4.0.0"
},
"devDependencies": {
"jest": "30.0.4",
"serverless-offline": "14.7.4",
"supertest": "7.1.0"
}
}
Your environment is ready. You have the Serverless Framework for deploying to Lambda, AWS credentials for creating resources, and a project scaffold with all the packages you need. Next up, you'll build the actual Express API and run it locally.
Build the Express API and Run It Locally
Your tools are installed and your project directory is ready. Before thinking about Lambda or deployment, you need a working API you can run and test on your own machine.
Building locally first lets you iterate fast and prove your routes work. In this step, you'll build a URL shortener Express API with three endpoints and verify each one with curl.
In this step, get ready to:
- Create an Express app with helmet, cors, and an in-memory data store.
- Add three API routes: shorten a URL, redirect to the original, and view click stats.
- Test all routes locally with curl to confirm they work before deploying.
Create the Express app and local server
The URL shortener needs an Express app that stores links in memory and a small entry-point file that starts the server on a local port.
- Create the src directory inside your project by running this command:
mkdir src
- Create a file called src/app.js and paste the following foundation code:
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const { nanoid } = require('nanoid');
const app = express();
// Security and parsing middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// In-memory data store for short links
const links = new Map();
// Validates that a string is a proper HTTP or HTTPS URL
function isValidUrl(string) {
try {
const url = new URL(string);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
module.exports = app;
What does this code do?
- helmet() adds security headers to every response, protecting against common web vulnerabilities.
- cors() allows requests from any origin, so frontends on other domains can call your API.
- links is a JavaScript Map that holds every shortened URL in memory while the server runs.
- isValidUrl uses the built-in URL constructor to check whether a string is a real HTTP or HTTPS address.
- Now create src/local.js to start the app on a local port. Paste this code:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`URL Shortener API running at http://localhost:${PORT}`);
});
What does this file do?
This file imports the Express app and calls app.listen() to start an HTTP server on port 3000.
Keeping the server startup separate from the app logic means you can later wrap the same app for Lambda without changing this file.
- Save both files.
- Start the server by running:
node src/local.js
You should see this output in your terminal:
URL Shortener API running at http://localhost:3000
Don't see the message?
- Make sure you saved both src/app.js and src/local.js before running the command.
- Check that you're in the url-shortener-api directory (not inside src).
- If you see Cannot find module, confirm the file paths match exactly: src/app.js and src/local.js.
Having trouble?
Add the URL shortening routes
The server is running but has no endpoints yet. Time to add the routes that make it a URL shortener.
- Stop the running server by pressing Ctrl+C in your terminal.
- In src/app.js, find the line that says module.exports = app; and paste the following code above it:
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.post('/shorten', (req, res) => {
const { url } = req.body;
if (!url || !isValidUrl(url)) {
return res.status(400).json({ error: 'A valid HTTP or HTTPS URL is required' });
}
const code = nanoid(8);
links.set(code, { url, clicks: 0, createdAt: new Date().toISOString(), clickLog: [] });
res.status(201).json({ code, shortUrl: `/${code}` });
});
What do these routes do?
- GET /health returns a simple JSON status check. Useful for monitoring later.
- POST /shorten takes a URL from the request body, validates it, generates an 8-character short code with nanoid, and stores the link data in the Map.
- The response returns the generated code and the short URL path the caller can share.
- Save src/app.js and restart the server:
node src/local.js
- Open a second terminal window and test the shorten endpoint by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
You should see a JSON response like this (your code will be different):
{"code":"a1B2c3D4","shortUrl":"/a1B2c3D4"}
Getting an empty response or connection refused?
- Make sure the server is still running in your first terminal (you should see the startup message).
- Confirm you're hitting port 3000 and that no other process is using it.
- If you see Cannot find module, check that the route code is above module.exports = app; in src/app.js.
Help me debug my /shorten endpoint
- Now add the redirect and stats routes. Stop the server with Ctrl+C in your first terminal.
- In src/app.js, find module.exports = app; again and paste the following code above it (below the /shorten route):
app.get('/stats/:code', (req, res) => {
const { code } = req.params;
const link = links.get(code);
if (!link) {
return res.status(404).json({ error: 'Short link not found' });
}
res.json({ code, url: link.url, clicks: link.clicks, createdAt: link.createdAt, clickLog: link.clickLog });
});
app.get('/:code', (req, res) => {
const { code } = req.params;
const link = links.get(code);
if (!link) {
return res.status(404).json({ error: 'Short link not found' });
}
link.clicks += 1;
link.clickLog.push({ timestamp: new Date().toISOString() });
res.redirect(302, link.url);
});
What do these routes do?
- GET /stats/:code looks up a short link by its code and returns analytics: total clicks, creation time, and a log of every click timestamp.
- GET /:code looks up the original URL, increments the click count, logs the timestamp, and sends a 302 redirect to the original destination.
The stats route is defined before the redirect route because Express matches routes top-to-bottom. If /:code came first, a request to /stats/abc123 would match /:code with code equal to stats.
- Save src/app.js and restart the server:
node src/local.js
- In your second terminal, shorten a fresh URL to get a code:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
- Copy the code value from the response and test the redirect (replace YOUR_CODE with your actual code):
curl -i http://localhost:3000/YOUR_CODE
You should see a 302 status with a Location: https://github.com header.
- Now check the click stats for the same code:
curl http://localhost:3000/stats/YOUR_CODE
The response shows clicks: 1 and a clickLog array with the timestamp of your redirect request.
Seeing 'Short link not found' for everything?
- Remember that restarting the server clears all in-memory data. You need to shorten a new URL after each restart before testing redirect or stats.
- Check that you copied the code exactly from the /shorten response. Codes are case-sensitive.
Help me debug my redirect and stats routes
✔️ Awesome, I've got everything!
Great. Double check you've saved src/app.js with all the routes in place.
ⓧ I'd like to double check the full code
Your src/app.js should contain these sections in order. Scroll up and confirm each is present:
- Imports (express, helmet, cors, nanoid) and middleware setup.
- The links Map and isValidUrl function.
- GET /health and POST /shorten routes.
- GET /stats/:code and GET /:code routes.
- module.exports = app; at the very end.
Your src/local.js should look like this:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`URL Shortener API running at http://localhost:${PORT}`);
});
Verify input validation
The API already includes validation logic that rejects bad input. Let's confirm it works correctly.
- Test sending an invalid URL by running:
curl -X POST http://localhost:3000/shorten -H "Content-Type: application/json" -d '{"url":"not-a-url"}'
You should see a 400 response:
{"error":"A valid HTTP or HTTPS URL is required"}
- Test requesting an unknown short code:
curl http://localhost:3000/nonexistent
You should see a 404 response:
{"error":"Short link not found"}
Why validate at the API layer?
Returning clear error codes (400 for bad input, 404 for missing resources) is what separates a production API from a toy script. Clients calling your API can handle each code differently instead of guessing what went wrong.
Before you run the final check, predict: what do you think will happen if you stop the server, restart it, and try to access the code you shortened earlier?
- Stop the server with Ctrl+C, then start it again:
node src/local.js
- Try accessing the old short code:
curl http://localhost:3000/stats/YOUR_CODE
You get Short link not found. The in-memory Map was wiped when the process restarted. This limitation is fine for now. You'll address it with a real database in the Secret Mission.
Your Express API is working locally with full input validation and three functional endpoints. Next up, you'll wrap this exact app for AWS Lambda and deploy it to the cloud with a single command.
Wrap for Lambda and Deploy to AWS
Your API is working on your machine. Every route responds correctly and you can shorten, redirect, and check stats locally.
But your machine is not a server anyone else can reach. In this step, you'll deploy your Express app to AWS Lambda so it runs in the cloud. A tiny wrapper file makes Express compatible with Lambda's event model, and a YAML config file tells the Serverless Framework what to deploy.
In this step, get ready to:
- Create a Lambda handler that wraps your Express app.
- Write the Serverless Framework configuration file.
- Deploy to AWS and test your live API with curl.
Create the Lambda handler
Lambda doesn't speak Express. It sends your function an event object and expects a response object back. The serverless-http library translates between Lambda's format and Express's request/response model.
- Create a new file called src/handler.js in your code editor with the following code:
const serverless = require('serverless-http');
const app = require('./app');
module.exports.handler = serverless(app);
What does this code do?
This file is the bridge between Lambda and your Express app.
- serverless-http wraps your Express app so it can understand Lambda's incoming event and return a properly formatted response.
- module.exports.handler is the entry point Lambda calls when a request arrives.
- Save src/handler.js.
- Confirm the file exists by running:
ls src/
You should see app.js, handler.js, and local.js listed.
✔️ Awesome, I've got everything!
Great. Make sure you've saved src/handler.js before moving on.
ⓧ I'd like to double check the full code
const serverless = require('serverless-http');
const app = require('./app');
module.exports.handler = serverless(app);
Create the Serverless Framework configuration
The Serverless Framework uses a single YAML file to define your entire cloud infrastructure. This is Infrastructure as Code. Instead of clicking through the AWS console, you declare what you need and the framework provisions it for you.
- Create a new file called serverless.yml in the root of your url-shortener-api folder with this configuration:
service: url-shortener-api
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
memorySize: 256
timeout: 29
stage: ${opt:stage, 'dev'}
functions:
api:
handler: src/handler.handler
events:
- httpApi: '*'
plugins:
- serverless-offline
custom:
serverless-offline:
httpPort: 3000
What does this configuration do?
- provider sets the cloud target: AWS, Node.js 22 runtime, 256 MB memory, and a 29-second timeout (just under API Gateway's 30-second limit).
- functions.api.handler points to the handler export in src/handler.js. This is the function Lambda invokes on every request.
- httpApi: '*' creates an API Gateway HTTP API that forwards all HTTP methods and paths to your single Lambda function.
- serverless-offline plugin lets you emulate Lambda locally for development without deploying.
- Save serverless.yml.
- Test your configuration locally by running:
npx serverless offline
You should see output confirming the server is running locally with your routes available on http://localhost:3000.
- Open a second terminal and test it by running:
curl http://localhost:3000/health
You should see {"status":"ok"} in the response.
- Return to the terminal running serverless offline and press Ctrl+C to stop it.
Seeing an error when starting serverless offline?
- Check that serverless.yml is in the root of your url-shortener-api folder, not inside src/.
- Make sure the indentation uses spaces, not tabs. YAML is whitespace-sensitive.
- Confirm serverless-offline is listed in your devDependencies by checking package.json.
Still stuck? Help me debug why serverless offline won't start in my url-shortener-api project.
✔️ Awesome, I've got everything!
Great. Make sure you've saved serverless.yml before moving on.
ⓧ I'd like to double check the full code
service: url-shortener-api
provider:
name: aws
runtime: nodejs22.x
region: us-east-1
memorySize: 256
timeout: 29
stage: ${opt:stage, 'dev'}
functions:
api:
handler: src/handler.handler
events:
- httpApi: '*'
plugins:
- serverless-offline
custom:
serverless-offline:
httpPort: 3000
Deploy and test live
Your configuration works locally. Now it's time to send it to AWS for real.
Before you run this, what do you think the deploy command will output? Think about what AWS resources need to be created for your API to be reachable on the internet.
- Deploy your stack to AWS by running:
npx serverless deploy
What happens during deploy?
The Serverless Framework packages your code, uploads it to S3, creates a CloudFormation stack, provisions your Lambda function, and wires up an API Gateway HTTP API endpoint. This takes 1-2 minutes on the first deploy.
When it finishes, the output displays your live API Gateway URL. It looks like https://<id>.execute-api.us-east-1.amazonaws.com. Copy this URL for testing.
- Test your live API by shortening a URL. Replace the URL below with the one from your deploy output:
curl -X POST [[API_URL="your API Gateway URL from the deploy output"]]/shorten -H "Content-Type: application/json" -d '{"url":"https://github.com"}'
You should see a JSON response with a code and shortUrl field. Your API is live on the internet.
- Test the redirect by opening your API URL followed by the short code in your browser. For example:
curl -I [[API_URL="your API Gateway URL from the deploy output"]]/[[SHORT_CODE="the code from the shorten response"]]
You should see a 302 redirect with a location header pointing to https://github.com.
- Check the stats for your short link by running:
curl [[API_URL="your API Gateway URL from the deploy output"]]/stats/[[SHORT_CODE="the code from the shorten response"]]
You should see a JSON response showing clicks: 1 and a clickLog array with one timestamp entry.
Getting a 403 or 500 error?
- Double-check that you copied the full API Gateway URL including https://.
- Make sure your AWS credentials are still valid by running aws sts get-caller-identity.
- Check the deploy output for any error messages. If the stack failed to create, run npx serverless deploy again.
Still stuck? Help me debug why my serverless deploy failed or why I'm getting errors hitting my live API Gateway URL.
Why do links disappear between requests?
You might notice that if you wait a few minutes and try to access your short link again, it returns a 404. This is because your links are stored in an in-memory Map inside the Lambda function.
Lambda recycles containers after periods of inactivity (cold starts). When a new container spins up, the Map is empty. This is the fundamental trade-off of in-memory storage in serverless environments. The Secret Mission at the end of this project solves this with DynamoDB persistence.
Your URL shortener is live on AWS. Anyone with the API Gateway URL can shorten links and follow redirects. Next up, you'll add automated tests so you can ship changes with confidence.
Add Automated Tests
Your API is live on AWS and handling requests. But right now, you are deploying without any safety net. One typo in a route handler could break the entire API for everyone using it.
Automated tests act as a gate that proves your API works before any deployment goes out. In this step, you'll write integration tests using Jest and Supertest that exercise every route, confirming correct behavior before code ever reaches production.
In this step, get ready to:
- Write integration tests covering all API routes.
- Run the test suite and confirm all tests pass.
Write integration tests for the API
Integration tests hit your actual Express app the same way a real HTTP client would. Supertest lets you make requests to your Express app without starting a server, and Jest runs the tests and reports results.
- Create a tests directory inside your url-shortener-api folder by running this command:
mkdir tests
- Confirm the directory was created by running:
ls
You should see tests listed alongside src, node_modules, and package.json.
- Create tests/api.test.js and add the imports, describe block, and your first two tests (creation and validation) by pasting this code:
const request = require('supertest');
const app = require('../src/app');
describe('URL Shortener API', () => {
let shortCode;
test('POST /shorten creates a short link', async () => {
const res = await request(app)
.post('/shorten')
.send({ url: 'https://github.com' })
.expect(201);
expect(res.body.code).toBeDefined();
expect(res.body.shortUrl).toBeDefined();
shortCode = res.body.code;
});
test('POST /shorten rejects invalid URLs', async () => {
await request(app)
.post('/shorten')
.send({ url: 'not-a-url' })
.expect(400);
});
What does this code do?
- require('supertest') imports the HTTP testing library that sends requests to your Express app without needing a running server.
- require('../src/app') imports the actual Express app so Supertest can send requests directly to it.
- The first test sends a POST to /shorten with a valid URL, expects a 201 status, and saves the shortCode for later tests to use.
- The second test sends an invalid URL and expects a 400 error response.
- Add the redirect, stats, error, and health tests below the code you just wrote in the same file:
test('GET /:code redirects to original URL', async () => {
const res = await request(app)
.get(`/${shortCode}`)
.expect(302);
expect(res.headers.location).toBe('https://github.com');
});
test('GET /stats/:code returns click analytics', async () => {
const res = await request(app)
.get(`/stats/${shortCode}`)
.expect(200);
expect(res.body.clicks).toBe(1);
expect(res.body.url).toBe('https://github.com');
expect(res.body.clickLog).toHaveLength(1);
});
test('GET /:code returns 404 for unknown code', async () => {
await request(app)
.get('/nonexistent')
.expect(404);
});
test('GET /health returns ok', async () => {
const res = await request(app)
.get('/health')
.expect(200);
expect(res.body.status).toBe('ok');
});
});
What does this code do?
- The redirect test hits GET /:code and checks for a 302 status with the correct location header pointing to the original URL.
- The stats test verifies that the click counter incremented to 1 after the redirect test ran, proving the analytics tracking works.
- The 404 test sends a request with a code that does not exist and expects a 404 response.
- The health test confirms the /health endpoint returns 200 with {"status":"ok"}.
These tests run in order because later tests depend on the short code created by the first test.
- Save tests/api.test.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved tests/api.test.js and move on to running the suite.
ⓧ I'd like to double check the full code
Your tests/api.test.js should contain, in order:
- The require imports for supertest and ../src/app.
- A describe('URL Shortener API') block wrapping all tests.
- A let shortCode variable at the top of the describe block.
- Six test() blocks: POST /shorten (201), POST /shorten invalid (400), GET /:code redirect (302), GET /stats/:code analytics (200), GET /:code unknown (404), GET /health (200).
- A closing }); for the describe block.
Scroll up and confirm each chunk is present in the right order.
Run the test suite
Your package.json already has the test script configured from when you set up the project. It runs jest --detectOpenHandles which finds your test files and shuts down cleanly even if async handles remain open.
Before you run the tests, do you think all six will pass on the first try? Or will one of them fail?
- Run the test suite:
npm test
You should see output showing PASS tests/api.test.js with all six tests passing. The summary line confirms 6 passed, 0 failed.
What does the output mean?
- Each test name appears with a green checkmark indicating it passed.
- The --detectOpenHandles flag ensures Jest exits cleanly even when Express leaves sockets open after tests.
- If any test had failed, Jest would show the expected vs. received values and a stack trace pointing to the exact assertion that broke.
Tests failing or not found?
- If you see "No tests found", make sure your file is named api.test.js (not api.tests.js or api.spec.js). Jest looks for files matching .test.js by default.
- If the redirect test fails with "expected 302 but got 404", check that the tests run in order. The POST /shorten test must come first because it creates the shortCode that later tests use.
- If you see a module not found error for supertest, confirm you installed it earlier with npm install -D supertest@7.1.0.
Still stuck? Help me debug my failing Jest tests for the URL shortener API.
Your API now has a full test suite proving every route works correctly. Next up, you'll create a GitHub Actions pipeline that runs these tests automatically and deploys to AWS on every push to main.
Create the GitHub Actions CI/CD Pipeline
Your API is tested and deployed, but every time you make a change you have to manually run npm test and npx serverless deploy yourself. That is slow and error-prone.
Production teams solve this with a CI/CD pipeline that automatically runs tests on every push and deploys only when those tests pass. In this step, you will create a GitHub Actions workflow that does exactly that.
In this step, get ready to:
- Create a GitHub Actions workflow with test and deploy jobs.
- Configure GitHub secrets and push the project.
- Verify the automated pipeline runs end to end.
Create the GitHub Actions workflow
GitHub Actions workflows live in a special directory at the root of your repository. Each YAML file in .github/workflows/ defines a pipeline that GitHub runs automatically.
- Create the workflow directory structure by running these commands:
mkdir -p .github/workflows
- Confirm the directory was created by running:
ls .github/workflows
You should see an empty directory listed with no errors.
- Create the workflow file with the test job by pasting this into .github/workflows/deploy.yml:
name: Test and Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
What does this workflow do so far?
- on: triggers the workflow on pushes to main and on pull requests targeting main.
- The test job checks out code, installs Node.js 22, runs npm ci for a clean install, then runs your tests.
- If any test fails, the workflow stops and the deploy never happens.
- Now add the deploy job below the test job in the same file. Paste this directly after the last line of the test job (after run: npm test):
deploy:
needs: test
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Deploy to AWS
run: npx serverless deploy --stage prod
env:
SERVERLESS_ACCESS_KEY: ${{ secrets.SERVERLESS_ACCESS_KEY }}
What does the deploy job do?
- needs: test means this job only runs after the test job passes.
- The if condition ensures deploys only happen on pushes to main. Pull requests run tests but never deploy.
- aws-actions/configure-aws-credentials@v4 injects your AWS credentials from GitHub secrets into the runner environment.
- npx serverless deploy --stage prod deploys to a production stage, separate from the dev stage you deployed manually earlier.
- Save .github/workflows/deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
Your .github/workflows/deploy.yml file has two sections you pasted above. Scroll up and confirm both are present in order:
- The header (name, on triggers) and the test job.
- The deploy job directly below.
Make sure the deploy key is indented at the same level as test (two spaces under jobs:).
YAML indentation error?
YAML is sensitive to spaces. Make sure you use exactly 2 spaces per indent level, not tabs. The deploy: key must be at the same indent as test: under jobs:.
help me fix a YAML indentation error in my GitHub Actions workflow
Configure GitHub secrets and push
Your workflow references three secrets that GitHub needs to authenticate with AWS and the Serverless Framework. You will store these securely in your repository settings so they are never exposed in code.
- First, create a .gitignore file to keep sensitive and generated files out of your repository. Paste this into .gitignore at the project root:
node_modules/
.serverless/
.env
coverage/
What does this file do?
Git ignores any file or directory listed here. node_modules/ contains thousands of dependency files. .serverless/ holds deployment artifacts. Neither belongs in version control.
- Save .gitignore.
- Initialize a Git repository and make your first commit by running these commands:
git init
git add .
git commit -m "Initial commit: URL shortener API with CI/CD"
- Create a new GitHub repository and push your code by running:
git branch -M main
git remote add origin https://github.com/YOUR-USERNAME/url-shortener-api.git
git push -u origin main
Prefer the GitHub CLI?
If you have the GitHub CLI installed, you can replace the three commands above with a single command:
gh repo create url-shortener-api --public --source=. --remote=origin --push
This creates the repo, sets the remote, and pushes in one step.
Push rejected or remote already exists?
If you see an error about the remote already existing, you may have already added one. Run git remote -v to check, then git remote remove origin to remove it and try again.
help me fix a git push error for my url-shortener-api repository
Now you need to add three secrets so GitHub Actions can deploy on your behalf.
- Open your new repository on GitHub.
- Click the Settings tab.
- In the left sidebar under Security, click Secrets and variables, then click Actions.
- Click the Secrets tab.
- Click New repository secret.
- Add the first secret with name AWS_ACCESS_KEY_ID and paste your AWS Access Key ID as the value. Click Add secret.
- Repeat to add AWS_SECRET_ACCESS_KEY with your AWS Secret Access Key.
- Repeat once more to add SERVERLESS_ACCESS_KEY with your Serverless Framework access key from the Serverless Dashboard.
Where do I find my Serverless access key?
Log in to the Serverless Dashboard at app.serverless.com. Navigate to your account settings or access keys page to generate a new key. This is the SERVERLESS_ACCESS_KEY that the CI/CD environment uses to authenticate headlessly.
You should now see all three secrets listed on the repository secrets page.
Trigger a deployment and verify the pipeline
Your push to main already triggered the workflow. Time to watch it run.
- Open your repository on GitHub.
- Click the Actions tab.
- You should see a workflow run in progress or completed. Click on it to see the details.
Before you check the result, what do you predict will happen? Do you think both jobs (test and deploy) will pass on the first try?
✔️ I see a green checkmark on both jobs
Your pipeline ran successfully. The test job passed all 6 tests and the deploy job shipped your API to a production stage.
ⓧ The test job failed
Click on the failed test job to see which test broke. The most common cause is a dependency mismatch. Make sure your package.json and package-lock.json are both committed. npm ci requires a lockfile.
help me debug why my GitHub Actions test job is failing
ⓧ The deploy job failed
Click on the failed deploy job and expand the error step. Common causes:
- A secret name is misspelled. Double check that AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and SERVERLESS_ACCESS_KEY match exactly (all caps, underscores).
- Your AWS credentials do not have permission to create Lambda functions or API Gateways. Verify the IAM user has AdministratorAccess or equivalent.
help me debug why my GitHub Actions deploy job is failing with Serverless Framework
Now verify the deployed API is working. The deploy job output shows your new production API Gateway URL.
- Find your production URL in the deploy job logs. It will look something like https://xxxxxxxxxx.execute-api.us-east-1.amazonaws.com.
- Verify the health endpoint by running:
curl https://YOUR-PROD-URL.execute-api.us-east-1.amazonaws.com/health
You should see:
{"status":"ok"}
Not getting a response?
Make sure you are using the production URL from the deploy job output, not the dev URL from your earlier manual deploy. The production stage has a different API Gateway endpoint.
help me find my production API Gateway URL in the GitHub Actions deploy logs
Your URL shortener API now deploys automatically every time you push tested code to main. No more manual deploys. Next up, you will take on a Secret Mission to make your links persist across Lambda cold starts.
Secret mission
Add DynamoDB Persistence
Your URL shortener works, but every link vanishes when Lambda recycles its container. In this secret mission, you will add DynamoDB as a persistent store so your short links survive cold starts and scale automatically.
Clean Up Your Resources
Clean Up Your Resources
This project uses serverless architecture, so your AWS resources cost nothing at rest within Free Tier limits. Decide whether to keep your resources running or delete them entirely.
Resources you used:
- AWS CloudFormation stack (manages Lambda function, API Gateway HTTP API, and DynamoDB table).
- Local project folder url-shortener-api on your Desktop.
- GitHub repository with configured secrets.
- Serverless Framework CLI installed globally via npm.
Keep everything running
No action needed. Choose this if you want to keep experimenting with your URL shortener API.
- Your Lambda function only runs when it receives a request. No requests means no cost.
- Your DynamoDB table uses PAY_PER_REQUEST billing. You only pay for reads and writes, which stay well within the free tier at development scale.
- Your API Gateway HTTP API is free for the first 1 million calls per month during your first 12 months of AWS.
- The Serverless Framework CLI remains installed globally on your machine.
Pause - I'll come back to this later
With serverless architecture, there is nothing to pause. Your resources have zero idle cost because Lambda and DynamoDB only charge per request.
- Your Lambda function sleeps until called. No invocations means no charges.
- Your DynamoDB table stores data at rest for free within the 25 GB free tier allowance.
- If you want to stop the GitHub Actions pipeline from deploying, you can disable the workflow in the Actions tab of your repository. Click the workflow name, then the three-dot menu, then Disable workflow.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your Lambda function, API Gateway, DynamoDB table, and all stored links permanently.
- Delete all AWS resources by running this command from your url-shortener-api folder:
npx serverless remove
What does this command do?
This single command tears down the entire CloudFormation stack that Serverless Framework created. It deletes your Lambda function, API Gateway, DynamoDB table, and all associated IAM roles in one operation.
- Delete your local project files. On macOS/Linux, run:
cd ~/Desktop
rm -rf url-shortener-api
On Windows, run:
cd ~\Desktop
rmdir /s /q url-shortener-api
- Delete your GitHub repository. Go to your repository page, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name when prompted.
- Uninstall the Serverless Framework CLI by running:
npm uninstall -g serverless
That's a Wrap!
That's a Wrap!
Nice work! You just built and deployed a production-grade URL shortener API to AWS Lambda, complete with an automated pipeline that tests and ships your code on every push.
You've learned how to:
- Built a REST API with Express.js that shortens URLs, handles 302 redirects, and tracks click analytics.
- Wrapped Express for AWS Lambda using serverless-http and deployed the entire stack as Infrastructure as Code with the Serverless Framework.
- Created a CI/CD pipeline with GitHub Actions that automatically runs tests and deploys to production on every push to main.
- Secret Mission: Added DynamoDB persistence so your short links survive Lambda cold starts and scale without limits.
Ready to quiz yourself?