Build a TypeScript URL Shortener
Build a TypeScript URL shortener with PostgreSQL, Redis, and Docker.
Introduction
30 Second Summary
Long links are awkward to share. They are even harder to remember.
In this project, you will build a local URL shortener powered by a TypeScript API, PostgreSQL storage, and Redis caching. Docker Compose will start the three local services as one system.
What You'll Build
You will demo a compact link that opens its original destination while live logs reveal which data path handled the redirect.
By the end of this project, you'll have:
- A working short-link flow where a JSON request returns a reusable URL that opens its original destination.
- Observable cache behavior where the logs show CACHE MISS before DATABASE LOOKUP on the first redirect. The next redirect shows CACHE HIT.
- A one-command startup that brings the API, PostgreSQL, and Redis services online together.
- Secret Mission: Protect URL creation with an atomic Redis-backed fixed-window rate limiter.
Are there any prerequisites?
You need a Windows computer with Docker Desktop and Cursor installed. Node.js and npm tooling run inside containers, so you do not need to install them on Windows.
Before We Start
Before any hands-on work, take a moment to commit to the containerized TypeScript URL shortener you are building. This gives you a clear reason for practising API, database, caching, and service-orchestration patterns.
Set Up the Windows Workspace
The finished URL shortener uses several local services. A dependable Windows workspace keeps engine problems from looking like bugs in your TypeScript API.
Docker Desktop supplies the container engine. Windows PowerShell lets you confirm that Docker Compose can reach it.
Cursor holds the folder structure you build today. All Node.js tooling stays inside later containers.
In this step, get ready to:
- Start Docker Desktop with its engine ready.
- Verify Docker from Windows PowerShell.
- Prepare the url-shortener-typescript workspace in Cursor.
Start Docker Desktop and PowerShell
Docker commands depend on the local engine supplied by Docker Desktop. Starting that engine first gives every later container command somewhere to run.
- Press the Windows key to open Windows Search.
- Enter Docker Desktop in the search box.
- Press Enter to start Docker Desktop.
- Wait until Docker Desktop reports that its engine is running.
The engine can take a minute or two to start. That wait is normal while Docker Desktop prepares its local services.
- Press the Windows key to open Windows Search again.
- Enter Windows PowerShell in the search box.
- Press Enter to open Windows PowerShell.
Verify Docker and Docker Compose
The first check asks Docker for client details plus server details from the running engine. The second check confirms that the Compose plugin is available.
Before you run the checks, do you think both commands can reach the Docker tools they need?
- Verify the Docker engine and Compose plugin by running these commands in Windows PowerShell:
docker version
docker compose version
What Do These Commands Check?
- The first command prints information about the Docker client.
- It also prints information from the running Docker engine.
- The second command prints the installed Docker Compose version.
You should see version information from both commands without an error. That confirms your Docker engine and Compose plugin are ready for the project.
Do the Version Checks Fail?
- Wait for Docker Desktop to finish starting before trying the checks again.
- Start a fresh Windows PowerShell window after the engine is ready.
Still stuck? Help me troubleshoot my Docker version checks.
Prepare the project workspace
A single root folder keeps the API source close to its future container configuration. The nested app folder holds the application.
The src directory will hold the TypeScript source in the next step. This step creates directories only.
Before you run these commands, which folder do you expect to see inside app?
- Create the nested workspace from Windows PowerShell by running these commands:
Set-Location $HOME\Desktop
New-Item -ItemType Directory -Path "url-shortener-typescript\app\src"
Set-Location url-shortener-typescript
Get-ChildItem -Recurse
What Do These Commands Do?
- The first line moves PowerShell to your Desktop.
- The second line creates url-shortener-typescript with the nested app and src directories.
- The third line moves PowerShell into the new root folder.
- The final line lists every directory below that root.
- Read the final directory listing in Windows PowerShell.
You should see app in the listing. You should also see src nested beneath it.
Is the Folder Structure Different?
- Check that the first command placed PowerShell on your Desktop.
- Confirm that src sits inside app.
Need a second pair of eyes? Help me fix my workspace folders.
Cursor needs the root folder so its file tree represents the whole project. Opening the parent directory keeps later files beside the app folder when required.
- Press the Windows key to open Windows Search.
- Enter Cursor in the search box.
- Press Enter to open Cursor.
- Choose Cursor's folder-opening control.
- Select the url-shortener-typescript folder from your Desktop.
Before you inspect the file tree, do you expect any application files to exist yet?
- Inspect the project file tree in Cursor.
You should see app at the project root. You should see src nested inside it.
You should see no application files yet. That clean tree matches the workspace this step is meant to create.
Does Cursor Show the Wrong Folder?
- Close the current folder if Cursor shows your entire Desktop.
- Choose the url-shortener-typescript folder as the project root.
- Expand app to confirm that src is nested inside it.
Still seeing the wrong tree? Help me open the correct project root in Cursor.
Your Windows workspace is ready. Next up, you'll add the first API files and prove the container can return a healthy response.
Launch the TypeScript Health API
Your project folder is open in Cursor. Docker is ready to build the first runnable part of your URL shortener.
This health API gives you visible proof that TypeScript compiles inside the image. The health route also proves that Express can answer an HTTP request from a running container.
In this step, get ready to:
- Define the TypeScript project scripts and compiler settings.
- Create the Express health server and its Docker image.
- Launch the API service and confirm its healthy response.
Configure the TypeScript project
The package.json file defines the packages and scripts used by the Node.js project. The tsconfig.json file controls how the TypeScript compiler produces runnable JavaScript.
- Select the existing app folder in Cursor's Explorer sidebar.
- Create app/package.json by copying the code below:
{
"name": "url-shortener-typescript",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.23.1",
"redis": "6.3.0"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.10.2",
"@types/pg": "8.23.1",
"typescript": "7.0.2"
}
}
What does this file do?
- The build script compiles the TypeScript source.
- The start script runs the compiled server.
- The dependencies pin the packages used by the completed API.
- The development dependencies provide the compiler and type definitions needed during the image build.
- Save app/package.json.
- Confirm Cursor's Explorer lists package.json directly inside app.
Seeing a JSON warning?
- Confirm package.json sits directly inside app.
- Compare every quote and comma with the code block above.
Still stuck? Help me find the syntax problem in my package.json file.
- Return to the existing app folder in Cursor's Explorer sidebar.
- Create app/tsconfig.json by copying the code below:
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
How is TypeScript configured?
- The target setting selects the JavaScript language level produced by the compiler.
- The NodeNext settings align module handling with the Node.js runtime.
- The rootDir setting points the compiler at src.
- The outDir setting places compiled files under dist.
- The strict setting enables stronger checks during compilation.
- Save app/tsconfig.json.
- Confirm Cursor's Explorer lists tsconfig.json beside package.json.
Configuration file in the wrong place?
- Confirm the file name is exactly tsconfig.json.
- Check that include sits outside compilerOptions.
- Compare the braces with the code block above.
Need another pair of eyes? Help me check my TypeScript compiler configuration.
Build the health server image
A health route provides a small test for the whole runtime path. Its healthy JSON response confirms that the compiled server received the request.
- Select the existing app/src folder in Cursor's Explorer sidebar.
- Create app/src/server.ts by copying the code below:
import express, { type Express } from "express";
const app: Express = express();
const port = 5000;
app.use(express.json());
app.get("/health", (_request, response) => {
response.json({ status: "healthy" });
});
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
What does this server do?
- The app value holds the Express application.
- The port value selects port 5000.
- The JSON middleware prepares the application to read JSON request bodies.
- The /health route returns healthy as its status.
- The app.listen() call starts the HTTP server.
- Save app/src/server.ts.
- Confirm Cursor's Explorer lists server.ts inside app/src.
Seeing TypeScript warnings?
- Confirm server.ts sits inside app/src.
- Check every quote and brace against the code block above.
- Confirm the import uses the package name shown in package.json.
Need help with the route? Help me debug my TypeScript Express health server.
The Dockerfile turns the project files into a repeatable API image. It installs the pinned packages before compiling the server.
- Return to the existing app folder in Cursor's Explorer sidebar.
- Create app/Dockerfile by copying the code below:
FROM node:24.21.0-alpine
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
EXPOSE 5000
CMD ["npm", "start"]
How is the image built?
- The node:24.21.0-alpine image supplies the Node.js runtime.
- The WORKDIR instruction sets the location used by later image instructions.
- The first COPY instruction adds the package and compiler configuration.
- The second COPY instruction adds the TypeScript source.
- The final command starts the compiled API.
- Save app/Dockerfile.
- Confirm Cursor's Explorer lists Dockerfile directly inside app.
Dockerfile in the wrong folder?
- Confirm Dockerfile has no file extension.
- Move the file directly under app if it appears under app/src.
- Check that package.json appears beside it.
Still unsure about the layout? Help me check where my Dockerfile and TypeScript files belong.
Launch and test the API
Docker Compose describes how the image runs as a service. This first Compose model contains only the api service.
- Select the existing url-shortener-typescript folder at the top of Cursor's Explorer sidebar.
- Create compose.yaml by copying the code below:
services:
api:
build: ./app
ports:
- "127.0.0.1:5000:5000"
What does this Compose file do?
- The api service builds its image from app.
- The port mapping sends local requests on port 5000 to the same port inside the container.
- The 127.0.0.1 binding keeps the development API available only from your computer.
- Save compose.yaml.
- Return to the Windows PowerShell window from the previous step.
- Move PowerShell to the existing project folder by running this command:
Set-Location -Path "$HOME\Desktop\url-shortener-typescript"
What does this command do?
The command changes PowerShell's current location to url-shortener-typescript on your Desktop. Docker Compose can now find compose.yaml.
- Validate the Compose model by running:
docker compose config
What does this command check?
The command parses the Compose model without starting the service. This catches structural YAML problems before the image build.
You should see a rendered configuration containing one service named api. Its published port should include 5000.
Compose configuration not rendering?
- Confirm the PowerShell prompt points to url-shortener-typescript.
- Check that the file is named exactly compose.yaml.
- Compare the indentation under services with the code block above.
Need help reading the configuration problem? Help me debug my Docker Compose file.
The first build can take a few minutes while Docker downloads the Node.js image. It also installs the project packages during this run.
- Build the image and start the API container by running:
docker compose up --build -d
What does this command do?
- The --build option creates an image from app/Dockerfile.
- The -d option leaves the API running in the background.
- The image build compiles app/src/server.ts before the container starts.
You should see output reporting that the api container started. This is the only service in the current Compose model.
API container not starting?
- Confirm Docker Desktop is still running.
- Check that Dockerfile sits directly inside app.
- Compare the package versions in app/package.json with the earlier code block.
If the build stops, help me diagnose my Docker Compose build output.
Before you test the endpoint, what status do you expect the health route to return?
- Call the health endpoint from PowerShell by running:
Invoke-RestMethod -Uri http://localhost:5000/health
What does this request prove?
PowerShell sends a request to the local port published by Docker Compose. The response proves that the container started the compiled Express server.
You should see a status property whose value is healthy.
That is your first working slice complete. Your TypeScript API now compiles inside Docker and answers a real request on port 5000.
Health request not returning healthy?
- Confirm the build output reported that the api container started.
- Check that the route in app/src/server.ts is exactly /health.
- Confirm compose.yaml maps local port 5000 to container port 5000.
Still missing the response? Help me trace why my containerized health endpoint is unavailable.
✔️ Awesome, I've got everything!
Your five project files are saved. The api container is running with a healthy response.
ⓧ I'd like to double check the full code
- Compare each saved file with the complete versions below.
FROM node:24.21.0-alpine
WORKDIR /app
COPY package.json tsconfig.json ./
RUN npm install
COPY src ./src
RUN npm run build
EXPOSE 5000
CMD ["npm", "start"]
Dockerfile checkpoint
This file installs the dependencies before compiling the TypeScript server. Its final command starts the API.
{
"name": "url-shortener-typescript",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "5.2.1",
"pg": "8.23.1",
"redis": "6.3.0"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "24.10.2",
"@types/pg": "8.23.1",
"typescript": "7.0.2"
}
}
Package checkpoint
This file pins the project packages. It also defines the image's build and start scripts.
import express, { type Express } from "express";
const app: Express = express();
const port = 5000;
app.use(express.json());
app.get("/health", (_request, response) => {
response.json({ status: "healthy" });
});
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
Server checkpoint
This file creates the Express application. It exposes the healthy response on port 5000.
{
"compilerOptions": {
"target": "ES2024",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
Compiler checkpoint
This file compiles the source under src into JavaScript under dist.
services:
api:
build: ./app
ports:
- "127.0.0.1:5000:5000"
Compose checkpoint
This file builds the only running service. It publishes the API on local port 5000.
Your health API is running inside Docker. Next, you'll add PostgreSQL so each short URL survives container restarts.
Persist and Shorten URLs
Your TypeScript health endpoint proved that the Express API can answer requests. A useful short code also needs its original URL to survive container restarts.
This step adds durable storage with PostgreSQL. A database-generated identity becomes a compact Base62 code that the API can return to its caller.
In this step, get ready to:
- Create the URL table and add PostgreSQL to Docker Compose.
- Connect the API to PostgreSQL and encode identity values as Base62 short codes.
- Send a URL to the API and receive a reusable short URL.
Create durable PostgreSQL storage
The database needs a table that connects each generated identity to one long URL. The identity gives every row a unique number that your API can encode.
- Select the app folder in the Cursor Explorer sidebar.
- Click the New File button at the top of the Explorer sidebar.
- Enter init.sql as the file name.
- Define the URL table by pasting this code into app/init.sql:
CREATE TABLE IF NOT EXISTS urls (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
long_url text NOT NULL
);
What Does This SQL Do?
- The urls table stores one mapping per shortened URL.
- The id column receives a database-generated identity value.
- The long_url column stores the destination as required text.
- Save app/init.sql.
- Confirm that init.sql is listed inside the app folder in the Explorer sidebar.
Can't Find the SQL File?
Check that init.sql appears directly inside app. Move it there if Cursor created it beside compose.yaml.
Still stuck? Help me place init.sql in the correct project folder.
The SQL file initializes a fresh database volume. Docker Compose now needs a database service that mounts this file during the first startup.
- Return to compose.yaml in Cursor.
- Place your cursor on a new line below the existing api service.
- Add the database service and named volume by pasting this code:
db:
image: postgres:18.6-alpine
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql
- ./app/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d urlshortener"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
How Does the Database Service Work?
- The postgres:18.6-alpine image runs PostgreSQL inside its own container.
- The postgres_data volume keeps database files outside the container lifecycle.
- The initialization mount makes init.sql available when PostgreSQL prepares an empty data directory.
- The health check waits for the urlshortener database to accept connections.
- Save compose.yaml.
- Switch back to PowerShell from the previous step.
- Validate the expanded Compose model by running:
docker compose config
What Does This Check Prove?
The command parses the Compose model and prints its resolved configuration. A rendered api service plus a rendered db service confirms the YAML structure is valid.
You should see both services in the rendered output. The named postgres_data volume should appear near the bottom.
Compose Configuration Invalid?
- Check that db is indented beneath services.
- Check that the final volumes key begins at the same indentation level as services.
- Confirm that each YAML indentation level uses spaces.
Need another pair of eyes? Help me debug my PostgreSQL service in compose.yaml.
The API also needs connection settings for PostgreSQL. Compose service discovery lets the API reach the database by its service name, db.
- Replace the opening api section in compose.yaml with this expanded version:
services:
api:
build: ./app
ports:
- "127.0.0.1:5000:5000"
environment:
PGHOST: db
PGPORT: "5432"
PGDATABASE: urlshortener
PGUSER: postgres
PGPASSWORD: postgres
depends_on:
db:
condition: service_healthy
How Does the API Find PostgreSQL?
- The PGHOST value points the database client to the db service.
- The remaining environment values identify the database connection.
- The healthy dependency keeps the API waiting until PostgreSQL can accept connections.
- Save compose.yaml.
- Validate the completed Compose model by running:
docker compose config
What Should the Model Contain?
The rendered api service should contain its database environment values. Its dependency should point to the healthy db service.
The command should finish without a parsing error. Your database wiring is now valid before any containers change.
✔️ Awesome, I've got everything!
Your SQL table and Compose database service are ready. Keep both files saved.
ⓧ I'd like to double check the full code
Compare app/init.sql with this complete file.
CREATE TABLE IF NOT EXISTS urls (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
long_url text NOT NULL
);
Compare compose.yaml with this complete file.
services:
api:
build: ./app
ports:
- "127.0.0.1:5000:5000"
environment:
PGHOST: db
PGPORT: "5432"
PGDATABASE: urlshortener
PGUSER: postgres
PGPASSWORD: postgres
depends_on:
db:
condition: service_healthy
db:
image: postgres:18.6-alpine
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql
- ./app/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d urlshortener"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
Build the shortening route
The API needs one shared database pool for its queries. It also needs a fixed alphabet that converts each numeric identity into a compact code.
- Return to app/src/server.ts in Cursor.
- Replace the imports and declarations through app.use(express.json()); with this code:
import express, { type Express } from "express";
import pg from "pg";
const { Pool } = pg;
const app: Express = express();
const port = 5000;
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const pool = new Pool();
app.use(express.json());
What Does This Setup Provide?
- The Pool class reads the PostgreSQL environment values supplied by Compose.
- The shared pool object handles single database queries.
- The alphabet string provides 62 characters for encoding identity values.
- Save app/src/server.ts.
- Rebuild the API with its PostgreSQL connection setup by running:
docker compose up --build -d
What Happens During This Build?
Compose creates the database container and the named volume. It rebuilds the API after PostgreSQL reports a healthy state.
- Confirm the running services by running:
docker compose ps
What Should Be Running?
The output should list the api service and the db service. The database should report a healthy state.
Good progress. Your API now has a reachable PostgreSQL service behind it.
Database Not Healthy?
- Confirm that Docker Desktop is still running.
- Check that the init.sql mount begins with ./app/.
- Check that the database name is urlshortener in both the environment block and the health check.
Still waiting for a healthy service? Help me troubleshoot why my PostgreSQL Compose service is unhealthy.
Base62 repeatedly divides the identity by the alphabet length. Each remainder selects one character until the entire number has been encoded.
- Add the encoder below app.use(express.json()); in app/src/server.ts:
function base62Encode(value: number): string {
if (value === 0) {
return alphabet[0];
}
const characters: string[] = [];
let remaining = value;
while (remaining > 0) {
const index = remaining % alphabet.length;
characters.push(alphabet[index]);
remaining = Math.floor(remaining / alphabet.length);
}
return characters.reverse().join("");
}
How Does the Encoder Work?
- The characters array collects one alphabet character per division.
- The remaining value shrinks after each pass through the loop.
- Reversing the collected characters produces the final Base62 code.
- Save app/src/server.ts.
- Rebuild the API with the encoder by running:
docker compose up --build -d
What Does This Rebuild Confirm?
The TypeScript build compiles base62Encode() inside the container image. A successful detached startup confirms the new function passes compilation.
- Check the rebuilt services by running:
docker compose ps
What Should Stay Healthy?
The output should still list the API and database services. The database should remain healthy after the API rebuild.
The shortening route has two connected parts. Paste both pieces before saving the file so the handler stays complete.
- Add the route opening and request validation below the existing /health route:
app.post("/shorten", async (request, response) => {
const body = request.body as { url?: unknown };
if (typeof body.url !== "string" || body.url.length === 0) {
response.status(400).json({ error: "Missing url field" });
return;
}
What Does the Route Validate?
The handler reads the parsed JSON body. It stops the request when the url field is missing or empty.
- Paste the persistence block directly below the validation code:
try {
const result = await pool.query(
"INSERT INTO urls (long_url) VALUES ($1) RETURNING id",
[body.url],
);
const id = Number(result.rows[0]?.id);
if (!Number.isInteger(id)) {
throw new Error("PostgreSQL did not return an identity value");
}
const shortCode = base62Encode(id);
response.status(201).json({
short_url: `http://localhost:5000/${shortCode}`,
short_code: shortCode,
});
} catch (error) {
console.error("Shorten error", error);
response.status(500).json({ error: "Could not shorten URL" });
}
});
How Does the Shortening Flow Work?
- The parameterized query sends body.url separately from the SQL statement.
- The RETURNING id clause gives the handler the generated identity.
- The encoder converts that identity into shortCode.
- The response returns both short_url and short_code with HTTP status 201.
- Save app/src/server.ts.
- Rebuild the API with the shortening route by running:
docker compose up --build -d
What Does This Build Add?
The rebuilt API contains the database-backed POST /shorten route. PostgreSQL keeps the inserted URL after the request finishes.
Before you send the request, what two values do you think the API will return?
- Create a short URL by running:
Invoke-RestMethod -Method Post -Uri http://localhost:5000/shorten -ContentType "application/json" -Body '{"url":"https://example.com"}'
What Does This Request Do?
PowerShell sends a JSON body to the shortening route. It converts the JSON response into an object whose fields appear in the terminal.
You should see a short_url value and a short_code value. That response proves the API inserted the URL and encoded its generated identity.
Shortening Request Failed?
- Confirm that the db service reports a healthy state.
- Check that the SQL query contains the $1 placeholder.
- Check that the query values array contains body.url.
Need help tracing the request? Help me debug why POST /shorten cannot store my URL.
Rebuild and verify the stack
The route can query PostgreSQL after a request arrives. The startup path should also confirm that the database is reachable before the API begins listening.
- Find this listener at the bottom of app/src/server.ts:
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
What Does the Current Listener Do?
The current code starts accepting HTTP requests immediately. It does not confirm the database connection first.
- Replace the listener with this startup function:
async function start(): Promise<void> {
await pool.query("SELECT 1");
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
}
start().catch((error) => {
console.error("Startup error", error);
process.exit(1);
});
Why Check the Database at Startup?
- The initial SELECT 1 query confirms the pool can reach PostgreSQL.
- The API starts listening only after that query succeeds.
- The catch handler logs a startup failure before exiting with a nonzero status.
- Save app/src/server.ts.
✔️ Awesome, I've got everything!
Your completed server includes PostgreSQL startup validation and the shortening route. Keep app/src/server.ts saved.
ⓧ I'd like to double check the full code
Compare app/src/server.ts with this complete file.
import express, { type Express } from "express";
import pg from "pg";
const { Pool } = pg;
const app: Express = express();
const port = 5000;
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const pool = new Pool();
app.use(express.json());
function base62Encode(value: number): string {
if (value === 0) {
return alphabet[0];
}
const characters: string[] = [];
let remaining = value;
while (remaining > 0) {
const index = remaining % alphabet.length;
characters.push(alphabet[index]);
remaining = Math.floor(remaining / alphabet.length);
}
return characters.reverse().join("");
}
app.get("/health", (_request, response) => {
response.json({ status: "healthy" });
});
app.post("/shorten", async (request, response) => {
const body = request.body as { url?: unknown };
if (typeof body.url !== "string" || body.url.length === 0) {
response.status(400).json({ error: "Missing url field" });
return;
}
try {
const result = await pool.query(
"INSERT INTO urls (long_url) VALUES ($1) RETURNING id",
[body.url],
);
const id = Number(result.rows[0]?.id);
if (!Number.isInteger(id)) {
throw new Error("PostgreSQL did not return an identity value");
}
const shortCode = base62Encode(id);
response.status(201).json({
short_url: `http://localhost:5000/${shortCode}`,
short_code: shortCode,
});
} catch (error) {
console.error("Shorten error", error);
response.status(500).json({ error: "Could not shorten URL" });
}
});
async function start(): Promise<void> {
await pool.query("SELECT 1");
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
}
start().catch((error) => {
console.error("Startup error", error);
process.exit(1);
});
Before you rebuild, do you expect the API to start before or after PostgreSQL becomes healthy?
- Rebuild the completed stack by running:
docker compose up --build -d
What Does the Final Startup Prove?
Compose waits for the database health check before starting the API. The API then confirms its own database connection before listening on port 5000.
- Inspect the final service states by running:
docker compose ps
What Should You See?
You should see running api and db services. The database should show a healthy state.
Before the final request, predict whether PostgreSQL will generate the same identity or a new one.
- Verify URL creation by running:
Invoke-RestMethod -Method Post -Uri http://localhost:5000/shorten -ContentType "application/json" -Body '{"url":"https://example.com"}'
What Confirms Success?
PowerShell should print short_url and short_code values. Each successful insert receives a new identity before Base62 encoding.
That's the storage path working. Your API can now turn a long URL into a compact code backed by durable PostgreSQL data.
Final Request Still Failing?
- Confirm that both services appear in the Compose status output.
- Confirm that app/init.sql contains the urls table definition.
- Confirm that the startup function calls pool.query("SELECT 1") before app.listen().
Still blocked? Help me diagnose my final URL shortening request.
Your short codes now have durable mappings. Next, you'll make those codes redirect through PostgreSQL and expose the repeated lookup that caching will solve.
Redirect Through PostgreSQL
Your Docker Compose stack now runs the API beside PostgreSQL. Each long URL survives inside the database.
The API also returns a compact Base62 code. That code becomes useful when it sends a visitor to the original destination.
A database-only redirect can perform that job. Repeat visits can force the same lookup each time.
In this step, get ready to:
- Decode Base62 short codes back into database IDs.
- Build a database-backed redirect route.
- Prove that repeated visits trigger repeated database lookups.
Decode each short code
Encoding turns a database ID into a short code. Decoding reverses that calculation so the API can recover the original ID.
- Return to Cursor.
- In app/src/server.ts, find the closing brace of base62Encode.
- Insert the decoder immediately below that function:
function base62Decode(code: string): number | null {
let value = 0;
for (const character of code) {
const index = alphabet.indexOf(character);
if (index === -1) {
return null;
}
value = value * alphabet.length + index;
}
return value;
}
How does Base62 decoding work?
- The value variable starts at 0.
- Each character's position inside alphabet provides its numeric value.
- The value * alphabet.length + index calculation shifts the current number one Base62 place before adding the next digit.
- An unknown character returns null. This gives the redirect route a safe path to a 404 response.
- Save app/src/server.ts.
- Confirm base62Decode sits immediately above app.get("/health", (_request, response) => {.
Seeing a decoder error?
- Check that base62Decode appears after the complete base62Encode function.
- Compare the braces around the for loop with the code above.
- Confirm the calculation uses the existing alphabet.length value.
Still stuck? Help me debug the base62Decode function.
Add the database redirect route
An HTTP redirect sends the browser to the stored long URL. The new route uses the decoded ID to retrieve that URL.
- In app/src/server.ts, find app.post("/shorten", async (request, response) => {.
- Insert the following block immediately after the complete POST route:
app.get("/:shortCode", async (request, response) => {
const shortCode = request.params.shortCode;
try {
const id = base62Decode(shortCode);
if (id === null) {
response.status(404).json({ error: "Short URL not found" });
return;
}
console.log("DATABASE LOOKUP");
const result = await pool.query(
"SELECT long_url FROM urls WHERE id = $1",
[id],
);
const longUrl = result.rows[0]?.long_url;
if (typeof longUrl !== "string") {
response.status(404).json({ error: "Short URL not found" });
return;
}
response.redirect(302, longUrl);
} catch (error) {
console.error("Redirect error", error);
response.status(500).json({ error: "Could not resolve short URL" });
}
});
What does this route do?
- The :shortCode path segment captures the code from the browser's URL.
- The decoder converts that code into a database ID. An invalid code returns a 404 response.
- The DATABASE LOOKUP log records every trip to PostgreSQL.
- The parameterized query passes the ID separately from the SQL text. The database uses that ID to find the stored URL.
- The response.redirect(302, longUrl) call sends the browser to the destination. A missing row returns a 404 response.
- Save app/src/server.ts.
- Switch back to PowerShell from earlier.
- Rebuild the API with its new redirect route by running:
docker compose up --build -d
What does this command do?
- The --build option rebuilds the API image from the updated TypeScript source.
- The -d option leaves the services running in the background.
PowerShell returns to the prompt after the API image rebuilds. That compile check is done.
Your redirect route now runs inside the API container.
Did the rebuild fail?
- Read the first TypeScript issue shown in PowerShell. Compare that line with the route above.
- Confirm the redirect route appears after the complete POST route.
- Check that async function start(): Promise<void> { still appears below the redirect route.
Need another pair of eyes? Help me troubleshoot the API rebuild.
✔️ Awesome, I've got everything!
Your decoder is in place. The redirect route is in place too.
Keep app/src/server.ts saved for the redirect test.
ⓧ I'd like to double check the full code
Compare your app/src/server.ts file with this checkpoint:
import express, { type Express } from "express";
import pg from "pg";
const { Pool } = pg;
const app: Express = express();
const port = 5000;
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const pool = new Pool();
app.use(express.json());
function base62Encode(value: number): string {
if (value === 0) {
return alphabet[0];
}
const characters: string[] = [];
let remaining = value;
while (remaining > 0) {
const index = remaining % alphabet.length;
characters.push(alphabet[index]);
remaining = Math.floor(remaining / alphabet.length);
}
return characters.reverse().join("");
}
function base62Decode(code: string): number | null {
let value = 0;
for (const character of code) {
const index = alphabet.indexOf(character);
if (index === -1) {
return null;
}
value = value * alphabet.length + index;
}
return value;
}
app.get("/health", (_request, response) => {
response.json({ status: "healthy" });
});
app.post("/shorten", async (request, response) => {
const body = request.body as { url?: unknown };
if (typeof body.url !== "string" || body.url.length === 0) {
response.status(400).json({ error: "Missing url field" });
return;
}
try {
const result = await pool.query(
"INSERT INTO urls (long_url) VALUES ($1) RETURNING id",
[body.url],
);
const id = Number(result.rows[0]?.id);
if (!Number.isInteger(id)) {
throw new Error("PostgreSQL did not return an identity value");
}
const shortCode = base62Encode(id);
response.status(201).json({
short_url: `http://localhost:5000/${shortCode}`,
short_code: shortCode,
});
} catch (error) {
console.error("Shorten error", error);
response.status(500).json({ error: "Could not shorten URL" });
}
});
app.get("/:shortCode", async (request, response) => {
const shortCode = request.params.shortCode;
try {
const id = base62Decode(shortCode);
if (id === null) {
response.status(404).json({ error: "Short URL not found" });
return;
}
console.log("DATABASE LOOKUP");
const result = await pool.query(
"SELECT long_url FROM urls WHERE id = $1",
[id],
);
const longUrl = result.rows[0]?.long_url;
if (typeof longUrl !== "string") {
response.status(404).json({ error: "Short URL not found" });
return;
}
response.redirect(302, longUrl);
} catch (error) {
console.error("Redirect error", error);
response.status(500).json({ error: "Could not resolve short URL" });
}
});
async function start(): Promise<void> {
await pool.query("SELECT 1");
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
}
start().catch((error) => {
console.error("Startup error", error);
process.exit(1);
});
What this reference shows
This is the exact app/src/server.ts state for this checkpoint. It includes the decoder above the existing routes.
The new redirect route sits between the POST route and the start function.
Expose the repeated lookup
A redirect can look perfect in the browser while repeating hidden work behind the scenes. The API logs reveal the path taken by each visit.
- In PowerShell, locate the short_url value from your successful request in the previous step.
- Record that value here: your returned short URL.
Before you test it, predict whether the new route reaches the stored destination.
- Paste your returned short URL into your browser's address bar.
- Press Enter.
Your browser reaches the original destination. Good, your short code now resolves to its stored URL.
- Paste your returned short URL into the address bar again.
- Press Enter.
Your browser reaches the same destination for the second time.
Before you inspect the logs, predict how many DATABASE LOOKUP messages those two visits produced.
- Switch back to PowerShell.
- Inspect the latest API logs by running:
docker compose logs api --tail 10
What does this command show?
- The command reads logs from the api service.
- The --tail 10 option limits the output to the ten most recent lines.
You should see DATABASE LOOKUP twice for those visits.
You found the exact pressure point. Every successful visit still queries PostgreSQL.
Missing a database lookup?
- Confirm you used the short URL returned by the API.
- Confirm you pasted that same short URL into the browser twice.
- Check the PowerShell logs for a redirect issue before repeating the test.
Still not seeing two lookups? Help me trace the redirect requests.
The bottleneck is visible now. Next, you'll add Redis as a fast path for repeat visits.
Add Redis Cache-Aside Redirects
Your PostgreSQL redirect now reaches the correct destination. The logs also proved that every visit repeats the same database lookup.
This step adds Redis as the fast path for repeated redirects. Docker Compose keeps the cache beside your API while PostgreSQL remains the durable source of truth.
In this step, get ready to:
- Add a healthy Redis service to Docker Compose.
- Connect the API to Redis.
- Verify cache-aside behavior through the API logs.
Add Redis to Docker Compose
A cache-aside flow checks the cache before the database. A missing value comes from PostgreSQL before the API stores it in Redis for the next request.
Why cache-aside?
PostgreSQL keeps each URL mapping durable. Redis keeps recently requested mappings in memory for faster repeated access.
Populating Redis during the first redirect makes both paths visible. The logs show when the database recovers a missing value and when Redis handles the next visit.
- In Cursor, select compose.yaml from the file list.
- Add the Redis service immediately above the existing volumes section by copying this code:
redis:
image: redis:8.10.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
What does this service do?
- The redis service runs the pinned redis:8.10.2-alpine image.
- The health check asks Redis to respond before another service depends on it.
- Compose service discovery makes Redis reachable from the API at redis:6379.
- Save compose.yaml.
- Validate the new Redis service from the PowerShell panel by running:
docker compose config
What does this command check?
The command parses the Compose model and renders its resolved configuration. A rendered redis service confirms that the YAML structure is valid.
PowerShell prints the resolved Compose configuration without an error. The output now includes the Redis image and health check.
Compose configuration not valid?
- Check that redis: aligns with db: under services:.
- Check that every line beneath redis: uses consistent spaces.
Still stuck? Help me fix the Redis service indentation in my Compose file.
- Under the existing db entry inside depends_on add this Redis dependency:
redis:
condition: service_healthy
Why wait for Redis health?
The health condition delays the API until Redis responds successfully. This prevents the API from attempting its cache connection while the Redis process is still starting.
- Save compose.yaml.
- Validate the completed dependency model by running:
docker compose config
What does the resolved model prove?
The rendered api service now lists healthy dependencies for PostgreSQL and Redis. Compose can use those conditions when it starts the stack.
The command completes without an error. Your API now waits for both supporting services in the resolved configuration.
Redis dependency missing?
- Confirm that the redis dependency sits inside the API service's depends_on block.
- Confirm that the Redis service name uses lowercase characters in both locations.
Need another pair of eyes? Help me compare my API dependencies with my Redis service.
✔️ Awesome, I've got everything!
Your compose.yaml defines Redis and makes the API wait for its health check.
ⓧ I'd like to double check the full code
Your complete compose.yaml should match this file.
services:
api:
build: ./app
ports:
- "127.0.0.1:5000:5000"
environment:
PGHOST: db
PGPORT: "5432"
PGDATABASE: urlshortener
PGUSER: postgres
PGPASSWORD: postgres
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:18.6-alpine
environment:
POSTGRES_DB: urlshortener
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- postgres_data:/var/lib/postgresql
- ./app/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d urlshortener"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:8.10.2-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:
Connect the API to Redis
The Express API needs one shared Redis client for all redirect requests. The client also needs an error listener so connection problems appear in the container logs.
- In Cursor, select app/src/server.ts from the file list.
- Replace the opening section from import express, { type Express } from "express"; through app.use(express.json()); with this updated section:
import express, { type Express } from "express";
import pg from "pg";
import { createClient } from "redis";
const { Pool } = pg;
const app: Express = express();
const port = 5000;
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const pool = new Pool();
const cache = createClient({ url: "redis://redis:6379" });
cache.on("error", (error) => {
console.error("Redis client error", error);
});
app.use(express.json());
What does this code do?
- The createClient import provides the Node.js Redis client used by the API.
- The cache client connects to the Redis service name on port 6379.
- The error listener sends Redis connection failures to the API logs.
- Save app/src/server.ts.
- Build the updated API and start the three-service stack by running:
docker compose up --build -d
What does this command do?
Compose rebuilds the API image before starting the project in the background. The healthy dependency conditions hold the API until PostgreSQL and Redis are ready.
The TypeScript build also checks that the Redis import and client configuration are valid.
PowerShell returns to the prompt after the stack starts. The output shows the Redis container starting beside the existing services.
API build failing?
- Check that the Redis import appears directly below the existing pg import.
- Check that cache is declared directly below pool.
- Check that the connection URL matches redis://redis:6379 exactly.
Still seeing a build error? Help me debug the Redis client setup in server.ts.
- Inside start() add this line directly below await pool.query("SELECT 1");:
await cache.connect();
Why connect during startup?
The API establishes its Redis connection before it begins listening for requests. A failed connection reaches the existing startup error handler instead of leaving a partially ready API running.
- Save app/src/server.ts.
- Rebuild the API with its startup connection by running:
docker compose up --build -d
What changes during this rebuild?
The rebuilt API now connects to PostgreSQL and Redis before opening port 5000. Detached mode keeps all three containers running after PowerShell returns.
- Check the three service states by running:
docker compose ps
What should the service list prove?
The service list reports the current state of each Compose container. The API should be running while the database and Redis report healthy states.
You now have api, db, and redis in the service list. Your API can reach both data services through the Compose network.
A service not ready?
- Wait briefly before running the status command again because the health checks need successful responses.
- Inspect the API output if its container stopped during startup.
- Confirm that Docker Desktop still reports a running engine.
Need help reading the service states? Help me diagnose which Compose service is preventing the API from starting.
Route redirects through the cache
The redirect route can now ask Redis for a URL before decoding the short code. A cached value returns immediately while a missing value continues through the existing PostgreSQL lookup.
- Inside GET /:shortCode find const id = base62Decode(shortCode);.
- Replace that line with this cache lookup and fallback section:
const cachedUrl = await cache.get(shortCode);
if (cachedUrl) {
console.log("CACHE HIT");
response.redirect(302, cachedUrl);
return;
}
console.log("CACHE MISS");
const id = base62Decode(shortCode);
How does the cache branch work?
- The short code becomes the Redis key because each code identifies one stored URL.
- A cached string logs CACHE HIT before returning the redirect.
- A missing value logs CACHE MISS before the existing decoder and database query continue.
- Save app/src/server.ts.
- Confirm the new cache branch compiles by running:
docker compose up --build -d
What does this build confirm?
A successful build confirms that the redirect handler can call the connected Redis client. The stack restarts with the cache lookup on the request path.
The build completes successfully before PowerShell returns to the prompt. Your cache lookup is now part of the running redirect route.
Cache branch not compiling?
- Check that the cache lookup sits inside the route's existing try block.
- Check that cachedUrl uses the same capitalization in the condition and redirect.
- Check that the existing const id = base62Decode(shortCode); line remains below CACHE MISS.
Still blocked? Help me fix the cache lookup in my redirect route.
- Inside the same route, find the final response.redirect(302, longUrl); line.
- Replace that line with this cache write and redirect:
await cache.set(shortCode, longUrl);
response.redirect(302, longUrl);
Why store the database result?
The first redirect recovers the URL from PostgreSQL before storing it under the short code. The next request can use that cached string without another database query.
- Save app/src/server.ts.
✔️ Awesome, I've got everything!
Your redirect route now checks Redis first and repopulates the cache after a database lookup.
ⓧ I'd like to double check the full code
Your complete app/src/server.ts should match this file.
import express, { type Express } from "express";
import pg from "pg";
import { createClient } from "redis";
const { Pool } = pg;
const app: Express = express();
const port = 5000;
const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
const pool = new Pool();
const cache = createClient({ url: "redis://redis:6379" });
cache.on("error", (error) => {
console.error("Redis client error", error);
});
app.use(express.json());
function base62Encode(value: number): string {
if (value === 0) {
return alphabet[0];
}
const characters: string[] = [];
let remaining = value;
while (remaining > 0) {
const index = remaining % alphabet.length;
characters.push(alphabet[index]);
remaining = Math.floor(remaining / alphabet.length);
}
return characters.reverse().join("");
}
function base62Decode(code: string): number | null {
let value = 0;
for (const character of code) {
const index = alphabet.indexOf(character);
if (index === -1) {
return null;
}
value = value * alphabet.length + index;
}
return value;
}
app.get("/health", (_request, response) => {
response.json({ status: "healthy" });
});
app.post("/shorten", async (request, response) => {
const body = request.body as { url?: unknown };
if (typeof body.url !== "string" || body.url.length === 0) {
response.status(400).json({ error: "Missing url field" });
return;
}
try {
const result = await pool.query(
"INSERT INTO urls (long_url) VALUES ($1) RETURNING id",
[body.url],
);
const id = Number(result.rows[0]?.id);
if (!Number.isInteger(id)) {
throw new Error("PostgreSQL did not return an identity value");
}
const shortCode = base62Encode(id);
response.status(201).json({
short_url: `http://localhost:5000/${shortCode}`,
short_code: shortCode,
});
} catch (error) {
console.error("Shorten error", error);
response.status(500).json({ error: "Could not shorten URL" });
}
});
app.get("/:shortCode", async (request, response) => {
const shortCode = request.params.shortCode;
try {
const cachedUrl = await cache.get(shortCode);
if (cachedUrl) {
console.log("CACHE HIT");
response.redirect(302, cachedUrl);
return;
}
console.log("CACHE MISS");
const id = base62Decode(shortCode);
if (id === null) {
response.status(404).json({ error: "Short URL not found" });
return;
}
console.log("DATABASE LOOKUP");
const result = await pool.query(
"SELECT long_url FROM urls WHERE id = $1",
[id],
);
const longUrl = result.rows[0]?.long_url;
if (typeof longUrl !== "string") {
response.status(404).json({ error: "Short URL not found" });
return;
}
await cache.set(shortCode, longUrl);
response.redirect(302, longUrl);
} catch (error) {
console.error("Redirect error", error);
response.status(500).json({ error: "Could not resolve short URL" });
}
});
async function start(): Promise<void> {
await pool.query("SELECT 1");
await cache.connect();
app.listen(port, () => {
console.log(`API listening on port ${port}`);
});
}
start().catch((error) => {
console.error("Startup error", error);
process.exit(1);
});
- Rebuild the completed cache-aside API by running:
docker compose up --build -d
What does this final rebuild apply?
The rebuilt API includes the Redis lookup and cache write in the redirect route. The detached stack keeps the API ready for the two-request test.
- Create a fresh short URL from PowerShell by running:
Invoke-RestMethod -Method Post -Uri http://localhost:5000/shorten -ContentType "application/json" -Body '{"url":"https://example.com"}'
Why create a fresh short URL?
A new short code has no Redis entry yet. This guarantees that the first redirect exercises the cache-miss path before the second redirect tests the cache hit.
PowerShell prints a response containing short_url and short_code values. That response proves PostgreSQL stored another URL mapping.
- Record the returned short_url value here: your returned short URL.
- Return to the browser from earlier.
- Select the browser address bar.
- Paste your returned short URL into the address bar.
- Press Enter.
- Select the browser address bar again.
- Paste your returned short URL into the address bar again.
- Press Enter.
Both visits take you to the original destination. The difference now lives in the data path behind each redirect.
Before you inspect the logs, which three messages do you expect to see and in what order?
- Inspect the latest API activity by running:
docker compose logs api --tail 15
What does this command show?
The command prints the latest 15 log lines from the API service. The route's log messages reveal whether Redis or PostgreSQL handled each redirect.
You should see CACHE MISS followed by DATABASE LOOKUP for the first redirect. You should then see CACHE HIT for the second redirect.
That is the cache-aside loop working end to end. PostgreSQL recovered the first request before Redis handled the repeated visit.
Cache messages not in order?
- Create another fresh short URL if you tested a code that Redis had already cached.
- Enter the short URL itself for both visits because refreshing the destination page does not call the redirect route again.
- Check that await cache.set(shortCode, longUrl); appears immediately before the final redirect.
Still missing a cache hit? Help me trace my Redis cache-aside redirect flow.
Secret mission
Protect URL Creation with Rate Limiting
Your URL creation endpoint currently accepts every valid request. Add an atomic Redis-backed fixed-window rate limiter that permits 10 requests per client during each 60-second window.
Clean Up Your Resources
Clean Up Your Resources
Everything in this project runs locally through Docker Desktop, so there are no ongoing cloud costs. Your options are to keep the shortener running, pause its services, or remove its local resources.
Resources you used:
- A local Docker Compose stack containing the api, db, and redis containers.
- A PostgreSQL named volume called postgres_data containing your stored URL mappings.
- Local Redis data inside the redis container containing cached URLs and rate-limit counters.
- The url-shortener-typescript project folder on your Desktop containing compose.yaml and the app folder.
Keep everything running
This option keeps the URL shortener available for more testing. Its three containers continue using local memory until you stop them or close Docker Desktop.
- Take no action while you continue testing the shortener at http://localhost:5000.
- Keep the url-shortener-typescript folder on your Desktop.
- Keep the postgres_data volume so your stored URL mappings remain available.
Pause - I'll come back to this later
This option stops the running services to free local memory. Your project files and stored URL mappings remain ready for another session.
- Switch back to the Windows PowerShell window from earlier.
- Stop the complete Compose stack by running this command:
docker compose stop
What does this command do?
Docker Compose stops the api, db, and redis containers. PowerShell reports each service as stopped.
The postgres_data volume remains intact. Your project folder also stays on your Desktop.
- Resume the complete Compose stack later by running this command:
docker compose start
What happens when you resume?
Docker Compose restarts the existing containers. Your API becomes available again at http://localhost:5000.
Your saved PostgreSQL mappings return with the stack. PowerShell reports each service as started.
Delete - I don't want to use this again
This option removes the running stack, the stored database data, and the project folder. Deleting postgres_data permanently removes your shortened-link mappings.
- Switch back to the Windows PowerShell window from earlier.
- Remove the Compose containers, project network, and PostgreSQL volume by running this command:
docker compose down -v
What does this remove?
- The api, db, and redis containers are removed.
- The network created for the Compose stack is removed.
- The postgres_data volume and its stored URL mappings are removed.
- The Redis cache entries and rate-limit counters are removed with the redis container.
PowerShell reports each removed resource. Your url-shortener-typescript folder remains available until you delete it separately.
- Press the Windows key to open the search bar.
- Type File Explorer into the search box.
- Press Enter to open it.
- Select Desktop in the navigation pane.
- Right-click the url-shortener-typescript folder.
- Select Delete from the menu.
- Confirm that the url-shortener-typescript folder no longer appears on your Desktop.
Nice Work!
Nice Work!
You did it! You built a containerized TypeScript URL shortener that creates persistent links.
You've learned how to:
- Built an Express API with TypeScript inside Docker. Returned reusable short codes through Base62 encoding.
- Persisted URL mappings in PostgreSQL with identity keys and parameterized SQL. Resolved each valid short code through an HTTP 302 redirect.
- Ran a healthy three-service stack with Docker Compose. Proved cache-aside behavior in Redis through CACHE MISS, DATABASE LOOKUP, and CACHE HIT logs.
- Secret Mission: Protected POST /shorten with an atomic Redis-backed fixed-window rate limiter. Verified that excess requests receive HTTP 429 with Rate limit exceeded.
Ready to quiz yourself?