Break and Scale a URL Shortener
Prove a URL shortener breaks under load, then fix it with caching and scaling.
Introduction
30 Second Summary
Every time you click a shortened link, a system handles that request alongside thousands of others at the same instant. When that system can't keep up, links break and users leave.
In this project, you will build a URL shortener, deliberately break it under load, and fix it with three foundational scaling patterns. You'll measure every improvement with real load test data.
What You'll Build
You'll watch k6 pump 500 virtual users into your system and see sub-10ms response times where you previously saw timeouts.
By the end of this project, you'll have:
- A running FastAPI URL shortener in Docker where you paste any long URL, get a short code back, and watch it redirect in your browser.
- Load test results proving Redis caching dropped your p95 latency from seconds to milliseconds.
- An Nginx load balancer distributing traffic across 3 app instances, with k6 test scripts proving your throughput scales linearly.
- Secret Mission: A distributed rate limiter that blocks abusive clients with HTTP 429 responses while legitimate traffic flows uninterrupted.
Are there any prerequisites?
No prior system design or Docker experience is needed. This project includes setup instructions for every tool you'll use.
Before We Start
You're about to build a URL shortener that starts simple, breaks under load, and gets progressively faster as you apply real scaling patterns.
Before writing any application code, you need the right tools in place. This step gets your load testing tool, project structure, and Python environment ready so you can start building in the next step.
In this step, get ready to:
- Install k6 for load testing.
- Create the project directory structure.
- Set up a Python virtual environment with dependencies.
Install k6 for load testing
Grafana k6 is a load testing tool that lets you simulate hundreds or thousands of virtual users hitting your API at once. You'll use it throughout this project to prove whether your system can handle the pressure.
- Check if k6 is already installed by running this command:
k6 version
✔️ I see a version number
You should see output like k6 v2.1.0 followed by build details. k6 is ready to go.
ⓧ Command not found
- Install k6 using Homebrew by running:
brew install k6
- Once the installation finishes, verify it worked by running:
k6 version
You should now see version output like k6 v2.1.0 confirming the install succeeded.
Still seeing command not found?
Make sure Homebrew itself is installed. Run brew --version to check. If Homebrew is missing, install it from brew.sh first.
help me install k6 on my machine
Create the project directory structure
Your project needs three subdirectories to keep things organized: one for the application code, one for the load test scripts, and one for the Nginx configuration.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and its subdirectories by running these commands:
mkdir url-shortener
cd url-shortener
mkdir app nginx k6
- Confirm the directories exist by running:
ls
You should see app, k6, and nginx listed in the output.
Don't see all three folders?
Make sure you ran the mkdir command from inside the url-shortener directory. Run pwd to check your current location. It should end with url-shortener.
help me create the url-shortener project directories
Set up the Python virtual environment
A virtual environment keeps your project's Python packages isolated from the rest of your system. This prevents version conflicts between projects.
- Create the virtual environment by running this command from inside url-shortener:
python3 -m venv .venv
- Activate the virtual environment by running:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.
- Install the project dependencies by running:
pip install "fastapi[standard]" redis uvicorn
What are these packages?
- fastapi[standard] is the web framework you'll use to build the URL shortener API.
- redis is the Python client for connecting to a Redis cache (used in a later step).
- uvicorn is the server that runs your FastAPI app.
You should see pip downloading and installing packages. The output ends with Successfully installed followed by a list of package names.
Seeing a permissions error or install failure?
Make sure your virtual environment is active. Look for (.venv) at the start of your terminal prompt. If it's missing, run the activation command again.
help me install FastAPI, redis, and uvicorn in my virtual environment
Your tools are ready and your project structure is in place. Next up, you'll write the URL shortener itself and see it handle its first requests.
Build the Naive URL Shortener
Before you can stress-test a system, you need a system to stress. The goal of this step is to build the simplest possible URL shortener that works.
You will store all URL mappings in a plain Python dictionary. This is intentionally naive. A Python dict is fast enough for one user, but it lives inside a single process and disappears when the process stops. That fragility is exactly what you will expose with a load test later.
In this step, get ready to:
- Write a FastAPI app with two endpoints that shorten and redirect URLs.
- Write a k6 load test script that hammers your API with 50 virtual users.
- Run the load test and record your baseline performance numbers.
Create the FastAPI application
Your URL shortener needs two jobs: accept a long URL and return a short code, then redirect anyone who visits that short code back to the original URL.
- In Trae IDE, open the file app/main.py inside your url-shortener folder.
- Add the following code to set up the app and the POST endpoint:
import hashlib
import time
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
app = FastAPI()
# Simple in-memory storage (will break under load later)
db = {}
# Define the expected shape of incoming request data
class URLRequest(BaseModel):
url: str
@app.post("/shorten")
def shorten_url(request: URLRequest):
# Generate a unique 8-character short code from the URL + timestamp
short_code = hashlib.md5(
f"{request.url}{time.time()}".encode()
).hexdigest()[:8]
# Store the mapping in our dict
db[short_code] = request.url
return {"short_code": short_code, "short_url": f"http://localhost:8000/{short_code}"}
What does this code do?
The URLRequest class uses Pydantic to validate that incoming JSON has a url field. FastAPI rejects the request automatically if it does not.
The shorten_url function hashes the URL combined with the current timestamp to produce a unique 8-character code. It stores that code-to-URL mapping in the db dictionary and returns the short code to the caller.
- Save app/main.py.
- Start the server by running this command in your terminal (make sure you are in the url-shortener directory with your virtual environment active):
uvicorn app.main:app --port 8000
- Open a second terminal tab or window (keep uvicorn running in the first one).
- Test the POST endpoint by running:
curl -X POST http://localhost:8000/shorten -H "Content-Type: application/json" -d '{"url": "https://example.com"}'
You should see a JSON response containing a short_code and a short_url. That proves your POST endpoint works.
Seeing "Module not found" or "No module named app"?
Make sure you are running the command from the url-shortener directory, not from inside app/. The app.main:app path tells uvicorn to look for a folder called app in the current directory.
Also confirm your virtual environment is active. You should see (venv) or similar at the start of your terminal prompt.
help me debug a uvicorn module-not-found error for my FastAPI app
Now add the redirect endpoint. Stop the server with Ctrl+C in the first terminal, then add the following code below the shorten_url function in app/main.py:
@app.get("/{short_code}")
def redirect_to_url(short_code: str):
# Look up the original URL from our in-memory dict
url = db.get(short_code)
if not url:
raise HTTPException(status_code=404, detail="URL not found")
# Send the user to the original URL with a 301 redirect
return RedirectResponse(url=url, status_code=301)
What does this endpoint do?
When someone visits /{short_code}, the function looks up the code in the db dict. If found, it returns a 301 redirect to the original URL. If not found, it raises a 404 error.
- Save app/main.py.
- Restart the server:
uvicorn app.main:app --port 8000
- In your second terminal, shorten a URL again and then test the redirect by copying the short_url from the response and pasting it into your browser.
Your browser should land on https://example.com. Both endpoints are working.
Browser not redirecting?
Make sure you copied the full short_url from the curl response including the port number. It should look like http://localhost:8000/abc12345.
If you get a 404, the short code may have expired because you restarted the server (the in-memory dict is wiped on restart). Run the curl POST command again to create a fresh short code.
help me debug my URL shortener redirect endpoint
✔️ Awesome, I've got everything!
Great. Double check you have saved app/main.py before moving on.
ⓧ I'd like to double check the full code
import hashlib
import time
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
app = FastAPI()
db = {}
class URLRequest(BaseModel):
url: str
@app.post("/shorten")
def shorten_url(request: URLRequest):
short_code = hashlib.md5(
f"{request.url}{time.time()}".encode()
).hexdigest()[:8]
db[short_code] = request.url
return {"short_code": short_code, "short_url": f"http://localhost:8000/{short_code}"}
@app.get("/{short_code}")
def redirect_to_url(short_code: str):
url = db.get(short_code)
if not url:
raise HTTPException(status_code=404, detail="URL not found")
return RedirectResponse(url=url, status_code=301)
Write the k6 load test script
A working API means nothing if you cannot prove how it performs. k6 lets you simulate dozens (or thousands) of users hitting your endpoints simultaneously, then reports latency percentiles and throughput.
- Open the file k6/load_test.js in Trae IDE.
- Add the imports, configuration, and setup function:
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8000";
// Configure the load test: 50 virtual users for 10 seconds
export const options = {
vus: 50,
duration: "10s",
};
// Runs once before the test starts: create a short URL to test against
export function setup() {
const payload = JSON.stringify({ url: "https://example.com/test-page" });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(`${BASE_URL}/shorten`, payload, params);
const shortCode = JSON.parse(res.body).short_code;
return { shortCode };
}
What does the setup function do?
The setup() function runs once before the load test begins. It creates a single shortened URL that all 50 virtual users will hit during the test. This isolates what you are measuring (redirect throughput) from the setup noise.
The options object tells k6 to spin up 50 virtual users (vus) and run them for 10 seconds (duration).
- Below the setup function, add the default function that each virtual user runs in a loop:
// Each virtual user runs this function repeatedly for the test duration
export default function (data) {
const res = http.get(`${BASE_URL}/${data.shortCode}`, { redirects: 0 });
check(res, {
"status is 301": (r) => r.status === 301,
});
sleep(0.1);
}
What does the default function do?
Each virtual user calls this function in a loop. It hits the redirect endpoint and checks that the response is a 301. The redirects: 0 option tells k6 not to follow the redirect. That way you measure your server's response time, not the time to fetch the destination page.
The sleep(0.1) adds a 100ms pause between requests per user, simulating realistic traffic rather than a tight loop.
- Save k6/load_test.js.
✔️ Awesome, I've got everything!
Great. Confirm you have saved k6/load_test.js before running the test.
ⓧ I'd like to double check the full code
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8000";
export const options = {
vus: 50,
duration: "10s",
};
export function setup() {
const payload = JSON.stringify({ url: "https://example.com/test-page" });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(`${BASE_URL}/shorten`, payload, params);
const shortCode = JSON.parse(res.body).short_code;
return { shortCode };
}
export default function (data) {
const res = http.get(`${BASE_URL}/${data.shortCode}`, { redirects: 0 });
check(res, {
"status is 301": (r) => r.status === 301,
});
sleep(0.1);
}
Run the load test and record your baseline
Your API is running, your load test script is ready. Time to see how the naive URL shortener performs under pressure from 50 simultaneous users.
- Make sure uvicorn is still running in your first terminal. If you stopped it, restart it:
uvicorn app.main:app --port 8000
Before you fire the load test, predict: do you think all 50 users will get successful 301 responses for the full 10 seconds, or will some requests fail?
- In your second terminal, run the load test:
k6 run k6/load_test.js
After 10 seconds, k6 prints a summary table. Look for two key numbers: the http_req_duration p(95) value (your p95 latency) and the http_reqs rate (requests per second).
What are p95 latency and request rate?
The p95 value means 95% of all requests completed faster than that time. It is a standard way to measure "worst-case for most users" without being skewed by rare outliers.
The request rate tells you how many total requests per second your system handled. Together these two numbers define your system's throughput and responsiveness. Write them down. They are your baseline.
Seeing connection refused errors in k6?
This means your uvicorn server is not running on port 8000. Switch to your first terminal and confirm it says Uvicorn running on http://127.0.0.1:8000. If not, restart it with the command above.
help me fix k6 connection refused errors when load testing my local FastAPI server
Your naive URL shortener works and you have hard numbers proving it. Next up, you will put this same app inside a Docker container, crank the load to 200 users, and watch it buckle.
Dockerize and Load Test Until It Breaks
Your URL shortener works. You proved it handles 50 virtual users without breaking a sweat. But local development hides a critical truth about how your app will behave in production.
In production, your app runs inside a container with constrained CPU and memory. In this step, you will Dockerize the app, crank the load to 200 virtual users, and watch it buckle. The failure you see here is the evidence that justifies adding a caching layer next.
In this step, get ready to:
- Package the app in a Docker container with a single worker process.
- Ramp the load test to 200 virtual users over 30 seconds.
- Observe the app degrade under pressure and record your baseline numbers.
Containerize the app with Docker
Docker needs a list of your Python dependencies to install inside the container. Right now your packages live only in the virtual environment on your machine.
- Create a new file called app/requirements.txt inside the app/ folder with these pinned dependencies:
fastapi[standard]==0.141.1
redis==8.1.0
uvicorn==0.34.0
Why pin exact versions?
Pinning with == ensures every build uses the exact same package versions. Without pins, a new release could introduce breaking changes between your local machine and the container.
✔️ Awesome, I've got everything!
Great. Make sure you saved the file.
ⓧ I'd like to double check the full code
fastapi[standard]==0.141.1
redis==8.1.0
uvicorn==0.34.0
Now you need a Dockerfile that tells Docker how to build an image of your app.
- Create a file called Dockerfile in the url-shortener/ project root (not inside app/) with this content:
FROM python:3.12-slim
WORKDIR /code
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
What does this Dockerfile do?
- FROM python:3.12-slim starts from a minimal Python image, keeping the container small.
- COPY app/requirements.txt then RUN pip install installs dependencies before copying code. This layer gets cached so rebuilds are fast when only code changes.
- CMD runs uvicorn with a single worker on port 8000. One worker is intentional. It is the constraint that will buckle under load.
- Confirm both files exist by running:
ls Dockerfile app/requirements.txt
You should see both file paths printed back to you with no errors.
Seeing 'No such file or directory'?
- Make sure Dockerfile is in the project root (url-shortener/) and requirements.txt is inside the app/ subfolder.
- Confirm your terminal is in the url-shortener/ directory by running pwd.
- Help me figure out where I saved the files
✔️ Awesome, I've got everything!
Great. Make sure you saved the Dockerfile.
ⓧ I'd like to double check the full code
FROM python:3.12-slim
WORKDIR /code
COPY app/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
The last piece is a Docker Compose file that orchestrates your container. It tells Docker which services to run and how to wire them together.
- Create a file called docker-compose.yml in the url-shortener/ project root:
services:
app:
build: .
ports:
- "8000:8000"
What does this compose file do?
It defines one service called app that builds the image from your Dockerfile and maps port 8000 inside the container to port 8000 on your machine. For now, it is a single container. That is the ceiling you are about to hit.
- Build the Docker image by running:
docker compose build
You should see Docker pulling the Python base image and installing your dependencies. The final line shows a successful build with the image name.
Build failed?
- If you see a "Cannot connect to the Docker daemon" error, make sure Docker Desktop is running on your machine.
- If pip fails inside the build, double check that app/requirements.txt has the exact package names from the code block above.
- Help me debug my Docker build
✔️ Awesome, I've got everything!
Great. Save docker-compose.yml and move on.
ⓧ I'd like to double check the full code
services:
app:
build: .
ports:
- "8000:8000"
Crank up the load and break the container
Your previous test used a flat 50 virtual users for 10 seconds. That was a warm-up. Now you need a ramp pattern that gradually increases pressure so you can see exactly where the app starts to crack.
- In k6/load_test.js, find the options block that currently looks like this:
export const options = {
vus: 50,
duration: "10s",
};
- Replace the entire options block with this staged ramp pattern:
export const options = {
stages: [
{ duration: "10s", target: 50 },
{ duration: "20s", target: 200 },
{ duration: "10s", target: 0 },
],
};
What do these stages do?
- Stage 1 ramps from 0 to 50 users over 10 seconds (a gentle warm-up).
- Stage 2 ramps from 50 to 200 users over 20 seconds (increasing pressure).
- Stage 3 ramps back down to 0 over 10 seconds (cool-down).
This pattern mimics a real traffic spike. The degradation will be most visible during stage 2 as the single-worker container runs out of capacity.
- Save k6/load_test.js.
✔️ Awesome, I've got everything!
Great. The load test is ready to go.
ⓧ I'd like to double check the full code
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8000";
export const options = {
stages: [
{ duration: "10s", target: 50 },
{ duration: "20s", target: 200 },
{ duration: "10s", target: 0 },
],
};
export function setup() {
const payload = JSON.stringify({ url: "https://example.com/test-page" });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(`${BASE_URL}/shorten`, payload, params);
const shortCode = JSON.parse(res.body).short_code;
return { shortCode };
}
export default function (data) {
const res = http.get(`${BASE_URL}/${data.shortCode}`, { redirects: 0 });
check(res, {
"status is 301": (r) => r.status === 301,
});
sleep(0.1);
}
Now launch the containerized app and fire the load test at it.
- Start the container in the background by running:
docker compose up -d
- Verify the container is running by running:
docker compose ps
You should see one container with status Up and port 8000 mapped.
Container exited immediately?
- If the status shows Exited, check the logs by running docker compose logs app.
- If port 8000 is already in use (from a previous uvicorn session), stop the old process first with lsof -i :8000 to find the PID, then kill <PID>.
- Help me debug my container startup
Read the damage report
Before you run the load test, pause and think. You have a single Python process running inside a container with limited resources. 200 concurrent users are about to hit it. What do you think will happen to response times?
- Run the load test against your containerized app:
k6 run k6/load_test.js
The k6 summary prints to your terminal when the test finishes. Look for these key metrics:
- http_req_duration shows average, median, p(90), and p(95) response times.
- http_reqs shows total requests and throughput (requests per second).
- The checks line shows how many requests passed vs failed.
You will see the p95 latency climb sharply above 500ms, or outright failed requests in the summary. The single-worker container simply cannot process 200 concurrent connections fast enough.
Why did this break?
Your app runs on a single uvicorn worker. Under 50 users it handled requests fine. At 200 users, the Python process becomes a bottleneck. Every request queues behind the others, latency stacks up, and eventually requests start timing out.
The in-memory dictionary also means each container gets its own isolated store. If you tried to scale by adding more containers right now, they would not share data. This is the problem Redis solves in the next step.
- Write down your p95 latency and throughput numbers from the k6 output. You will compare them directly against the Redis-cached version in the next step.
You now have hard evidence that a single container with an in-memory store cannot scale. Next up, you will add Redis as a shared caching layer and watch those latency numbers plummet.
Add Redis Caching
Your load test proved the single-process URL shortener buckles under 200 concurrent users. The in-memory Python dict can't handle concurrent reads and writes efficiently, especially behind Python's GIL.
Redis is a high-throughput key-value store that runs as its own process, separate from your app. By moving your data from a Python dict into Redis, you decouple storage from the application process. This also sets you up for the next step, where multiple app instances will share the same Redis data store.
In this step, get ready to:
- Add a Redis service to your Docker Compose stack.
- Update your FastAPI app to read and write from Redis instead of an in-memory dict.
- Re-run the load test and measure the performance improvement.
Add Redis to your Docker stack
Your app needs a shared data store that lives outside the Python process. You'll add a Redis container to your Docker Compose file and rewire the FastAPI app to use it.
- In Trae, open docker-compose.yml and replace its entire contents with the following:
services:
app:
build: .
depends_on:
- redis
redis:
image: redis:7.4-alpine
What changed here?
- depends_on: - redis tells Docker to start the Redis container before the app container.
- The new redis service uses the official redis:7.4-alpine image. Alpine keeps the image small and fast to pull.
- The port mapping is removed from the app service because you'll access the app through a load balancer in the next step.
- Save docker-compose.yml.
Now update the app code to use Redis instead of the in-memory dict.
- Open app/main.py and replace its entire contents with the following:
import hashlib
import time
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
import redis
app = FastAPI()
# Connect to the Redis container by service name
r = redis.Redis(host="redis", port=6379, decode_responses=True)
class URLRequest(BaseModel):
url: str
@app.post("/shorten")
def shorten_url(request: URLRequest):
short_code = hashlib.md5(
f"{request.url}{time.time()}".encode()
).hexdigest()[:8]
# Store the short code in Redis instead of a dict
r.set(short_code, request.url)
return {"short_code": short_code, "short_url": f"http://localhost:8000/{short_code}"}
@app.get("/{short_code}")
def redirect_to_url(short_code: str):
# Retrieve the URL from Redis
url = r.get(short_code)
if not url:
raise HTTPException(status_code=404, detail="URL not found")
return RedirectResponse(url=url, status_code=301)
What does this code do?
- import redis brings in the redis-py client library (already in your requirements.txt from Step 1).
- redis.Redis(host="redis", port=6379, decode_responses=True) connects to the Redis container by its Docker Compose service name. The decode_responses=True flag returns strings instead of raw bytes.
- r.set(short_code, request.url) writes the URL mapping to Redis. r.get(short_code) reads it back. Redis handles these operations orders of magnitude faster than a Python dict under concurrency.
Why Redis over the Python dict?
A Python dict lives inside the app process. Under concurrent requests, Python's Global Interpreter Lock (GIL) serializes access to the dict, creating a bottleneck.
Redis runs as a separate, single-threaded but non-blocking process optimized for concurrent key-value operations. It also persists data independently from the app, so if the app container restarts, your URLs survive.
- Save app/main.py.
Rebuild and re-run the load test
- Stop the existing containers and rebuild with the new Redis service by running:
docker compose up -d --build
- Confirm both containers are running by running:
docker compose ps
You should see two services listed: app and redis, both in a running state.
Redis container not starting?
- Check that docker-compose.yml has the redis service indented at the same level as app.
- Run docker compose logs redis to see if there are startup errors.
- Make sure Docker Desktop is running and has enough memory allocated.
help me debug why my Redis container won't start in Docker Compose
Before you run the load test, think about what you expect to see. Do you think the p95 latency will improve, stay the same, or get worse compared to your Step 3 results?
- Run the same 200-user load test by running:
k6 run k6/load_test.js
Compare your results with Step 3
Look at the k6 summary output in your terminal. Compare these numbers against what you recorded in the previous step.
- Find the http_req_duration p(95) value. This should be dramatically lower than your Step 3 result.
- Check the total http_reqs count. Throughput (requests per second) should be noticeably higher.
- Look at the checks line to confirm all requests returned status 301.
Why is Redis faster under concurrency?
Python's GIL forces the in-memory dict to process one request at a time, even in an async framework. Redis handles concurrent read and write operations independently of your Python process.
The result: your app spends less time waiting on data access and more time serving responses. You proved this with real numbers, not theory.
✔️ Awesome, I've got everything!
Great. Double check that you've saved both docker-compose.yml and app/main.py.
ⓧ I'd like to double check the full code
Here is the full docker-compose.yml:
services:
app:
build: .
depends_on:
- redis
redis:
image: redis:7.4-alpine
Here is the full app/main.py:
import hashlib
import time
from fastapi import FastAPI, HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
import redis
app = FastAPI()
r = redis.Redis(host="redis", port=6379, decode_responses=True)
class URLRequest(BaseModel):
url: str
@app.post("/shorten")
def shorten_url(request: URLRequest):
short_code = hashlib.md5(
f"{request.url}{time.time()}".encode()
).hexdigest()[:8]
r.set(short_code, request.url)
return {"short_code": short_code, "short_url": f"http://localhost:8000/{short_code}"}
@app.get("/{short_code}")
def redirect_to_url(short_code: str):
url = r.get(short_code)
if not url:
raise HTTPException(status_code=404, detail="URL not found")
return RedirectResponse(url=url, status_code=301)
Your URL shortener now has a dedicated data layer that handles concurrency properly. Next up, you'll push the system even further by adding an Nginx load balancer and scaling to multiple app instances.
Scale Horizontally with Nginx Load Balancer
Redis caching dramatically improved your read performance. But there is still a hard ceiling: a single app process can only handle so many concurrent connections, no matter how fast the data layer behind it is.
The fix is horizontal scaling. Instead of making one container faster, you run multiple identical containers and spread traffic across them. Nginx will act as a load balancer in front of your app, distributing every incoming request to one of several workers.
In this step, get ready to:
- Create an Nginx configuration that load balances across app instances.
- Scale the app to 3 replicas and run the final load test with 500 virtual users.
- Observe linear throughput scaling and compare with your single-instance results.
Create the Nginx load balancer configuration
A load balancer sits between the outside world and your app containers. It accepts every incoming request and forwards it to one of the available workers, rotating through them so no single container gets overloaded.
- Create the nginx/ directory and a new file nginx/nginx.conf by running:
mkdir nginx
touch nginx/nginx.conf
- Confirm the files exist by running:
ls nginx/
You should see nginx.conf in the output.
- Open nginx/nginx.conf in Trae IDE and paste the following configuration:
events {
worker_connections 1024;
}
http {
upstream app_servers {
server app:8000;
}
server {
listen 80;
location / {
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
What does this configuration do?
- upstream app_servers defines a pool of backend servers. Right now it lists one (app:8000), but Docker Compose will register every replica under the same hostname.
- proxy_pass http://app_servers forwards each incoming request to one of the servers in the pool, cycling through them round-robin.
- proxy_set_header lines pass the original client IP and host to the app so logs remain accurate.
- Save nginx/nginx.conf.
Seeing a syntax error later when Nginx starts?
Make sure every opening brace { has a matching closing brace }. Nginx config is whitespace-insensitive, but missing braces will crash the container.
Check that the file is saved as nginx/nginx.conf (not nginx.conf in the project root).
Help me debug my Nginx configuration
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
events {
worker_connections 1024;
}
http {
upstream app_servers {
server app:8000;
}
server {
listen 80;
location / {
proxy_pass http://app_servers;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
}
Now you need to wire Nginx into your Docker Compose setup and point all traffic through it.
- Open docker-compose.yml and replace its contents with this updated version that adds the Nginx service:
services:
app:
build: .
depends_on:
- redis
redis:
image: redis:7.4-alpine
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
What changed in Docker Compose?
- The new nginx service uses the nginx:1.30-alpine image and exposes port 8080 on your machine, mapping to port 80 inside the container.
- The volumes mount injects your local nginx.conf into the container as read-only (:ro).
- The app service has no ports: mapping. Traffic reaches the app only through Nginx, never directly from the host.
- Save docker-compose.yml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
services:
app:
build: .
depends_on:
- redis
redis:
image: redis:7.4-alpine
nginx:
image: nginx:1.30-alpine
ports:
- "8080:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- app
Since the public-facing port is now 8080, you also need to update the short URL returned by the API.
- In app/main.py, find this line inside the shorten_url function:
return {"short_code": short_code, "short_url": f"http://localhost:8000/{short_code}"}
- Replace 8000 with 8080, so the line now reads:
return {"short_code": short_code, "short_url": f"http://localhost:8080/{short_code}"}
- Save app/main.py.
Launch with 3 replicas and run the load test
Docker Compose can spin up multiple copies of the same service with a single flag. Each copy is an independent container, but they all share the same Redis and sit behind the same Nginx.
- Stop the existing containers and launch the full stack with 3 app replicas by running:
docker compose down
docker compose up -d --build --scale app=3
- Verify all containers are running by running:
docker compose ps
You should see 5 containers: 3 app instances, 1 Redis, and 1 Nginx. All should show a status of Up.
Seeing fewer than 3 app containers?
If the Nginx container shows as exited, check that nginx/nginx.conf exists at the correct path. Docker Compose mounts it from your project root.
If app containers keep restarting, run docker compose logs app to see the error. A common cause is a missing comma or typo in app/main.py from the port change.
Help me debug why my containers aren't starting
Now update the k6 script to target the Nginx port and ramp up to 500 virtual users.
- In k6/load_test.js, find this line at the top:
const BASE_URL = "http://localhost:8000";
- Replace 8000 with 8080 so traffic goes through Nginx:
const BASE_URL = "http://localhost:8080";
- Next, find the stages array:
stages: [
{ duration: "10s", target: 50 },
{ duration: "20s", target: 200 },
{ duration: "10s", target: 0 },
],
- Replace it with this version that ramps to 500 virtual users:
stages: [
{ duration: "10s", target: 100 },
{ duration: "20s", target: 500 },
{ duration: "10s", target: 0 },
],
- Save k6/load_test.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
import http from "k6/http";
import { check, sleep } from "k6";
const BASE_URL = "http://localhost:8080";
export const options = {
stages: [
{ duration: "10s", target: 100 },
{ duration: "20s", target: 500 },
{ duration: "10s", target: 0 },
],
};
export function setup() {
const payload = JSON.stringify({ url: "https://example.com/test-page" });
const params = { headers: { "Content-Type": "application/json" } };
const res = http.post(`${BASE_URL}/shorten`, payload, params);
const shortCode = JSON.parse(res.body).short_code;
return { shortCode };
}
export default function (data) {
const res = http.get(`${BASE_URL}/${data.shortCode}`, { redirects: 0 });
check(res, {
"status is 301": (r) => r.status === 301,
});
sleep(0.1);
}
Before you run this, think about what you expect to happen. With 3 app instances behind Nginx instead of 1, how much do you think throughput will increase?
- Run the final load test by running:
k6 run k6/load_test.js
Observe the final results
When k6 finishes, look at the summary table in your terminal. Compare these numbers to what you recorded with a single app instance in the previous step.
- Find the http_reqs line. This is your total throughput (requests per second). It should be roughly 3x what you saw with a single instance.
- Find the http_req_duration p(95) value. This is your p95 latency. It should remain low despite handling 500 concurrent users.
- Check the checks line. You should see close to 100% of requests returning status 301.
Why does this work?
Your app containers are stateless. They do not store any data locally. Every short code lives in Redis, which all 3 containers share.
Because there is no local state, Nginx can send any request to any container and get the same result. This is what makes horizontal scaling possible. Adding more instances is purely an operational decision, not a code change.
Not seeing 3x throughput improvement?
Confirm all 3 app containers are healthy with docker compose ps. If one is restarting, the load is only split across 2.
Make sure your k6 script targets port 8080 (Nginx), not 8000. Hitting the app directly bypasses the load balancer entirely.
Help me understand my k6 load test results
Secret mission
Add Rate Limiting
Your system scales beautifully now. But what stops a single client from hogging all that capacity? In this secret mission, you'll build a distributed rate limiter using Redis that protects your infrastructure from abuse.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with Docker, so there are no ongoing cloud costs.
Resources you used:
- Docker containers (app x3, redis, nginx) running locally. These consume RAM and CPU while active.
- Local project files in the url-shortener folder on your Desktop.
- k6 load testing tool installed via Homebrew.
Keep everything running
No action needed. Choose this if you're still actively experimenting with your scaled system or want to try different load test configurations.
- Your Docker containers remain running and accessible at http://localhost:8080.
- k6 stays installed on your machine for future load testing projects.
- Your project files stay on your Desktop for further editing.
Pause - I'll come back to this later
Shut down running containers to free up memory and CPU, but keep them available to restart quickly later.
- Navigate to your project folder and stop the containers by running:
cd ~/Desktop/url-shortener
docker compose stop
This preserves your containers and their configuration. When you want to resume, run:
docker compose up -d --scale app=3
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Remove all Docker containers and volumes by running:
cd ~/Desktop/url-shortener
docker compose down -v
- Delete the project folder by running:
rm -rf ~/Desktop/url-shortener
- Uninstall k6 from your machine by running:
brew uninstall k6
That's a Wrap!
That's a Wrap!
Nice work! You just built a distributed system that handles 500 concurrent users, and you have the load test receipts to prove every scaling decision you made.
Here's what you accomplished:
- Built a FastAPI URL shortener and used k6 load tests to prove it breaks under concurrent traffic.
- Added a Redis caching layer that replaced the in-memory dict and measured the p95 latency drop in your test results.
- Scaled horizontally with an Nginx load balancer distributing traffic across 3 stateless app instances, achieving roughly linear throughput growth.
- Secret Mission: Implemented distributed rate limiting using Redis sliding window counters to protect your scaled system from abuse.
Ready to quiz yourself?