DEV Community

Joan Roucoux
Joan Roucoux

Posted on

Building a Scalable URL Shortener with Node.js (Part 1/2)

Introduction

I'm sure you're familiar with URL shortener tools like TinyURL and Bitly, as they are widely used online. It simply takes a long URL and creates a shorter, unique alias that redirects to the original link.

For example, a URL like https://www.example.com/movies/avengers-infinity-war/casting/chris-hemsworth could be shortened to something like https://short.url/abc123, making it much easier to read and share. And then, when the user accesses the shortened link, the browser requests the server, which looks up the alias in its database and redirects the user to the original URL.

This might look simple when you first think about it, but it's actually tricky to build which is why they are commonly used in system design interviews. So creating one yourself is a great way to practice and see the challenges involved (handling high traffic, concurrency and race conditions in key generation, data storage and scaling...).

In the first part of this tutorial, we will focus on developing the backend of our URL shortener application using the following stack:

  • Node.js: A JavaScript runtime to power the server instances.
  • MongoDB: A NoSQL database for storing original URLs.
  • Redis: In-memory data store for caching frequently accessed URLs.
  • Apache ZooKeeper: A centralized service to generate unique IDs and prevent race conditions between instances.
  • Nginx: A load balancer and reverse proxy to distribute traffic across server instances.
  • Docker: A containerization tool to manage all the services.

Here's the high-level architecture of the application:

URL Shortener App High Level Architecture

So let's jump into it πŸš€

Initializing the Project

First, let's see what our file tree will look like:

url-shortener-demo-app β”œβ”€ .env β”œβ”€ docker-compose.yml β”œβ”€ client - Part 2 of this tutorial β”œβ”€ nginx β”‚ β”œβ”€ Dockerfile β”‚ └─ nginx.conf └─ server β”œβ”€ src β”‚ β”œβ”€ config β”‚ β”‚ β”œβ”€ mongoose.ts β”‚ β”‚ β”œβ”€ redis.ts β”‚ β”‚ └─ zookeeper.ts β”‚ β”œβ”€ controllers β”‚ β”‚ └─ urlsController.ts β”‚ β”œβ”€ models β”‚ β”‚ └─ Url.ts β”‚ β”œβ”€ repositories β”‚ β”‚ └─ urlsRepository.ts β”‚ β”œβ”€ routes β”‚ β”‚ └─ urlsRoutes.ts β”‚ β”œβ”€ services β”‚ β”‚ └─ urlsService.ts β”‚ β”œβ”€ utils β”‚ β”‚ └─ index.ts β”‚ └─ index.ts β”œβ”€ .dockerignore β”œβ”€ Dockerfile β”œβ”€ package-lock.json β”œβ”€ package.json └─ tsconfig.json 
Enter fullscreen mode Exit fullscreen mode

Our project app url-shortener-demo-app will include a client folder for the React app that we will build in the second part of this tutorial, a nginx folder for Nginx configuration and a server folder for managing URL-related operations. It's a good thing to separate concepts like routers, controllers and services to enhance code organization and improve maintainability.

We will first start with the server code. Create a new directory for the project and initialize a new application:

mkdir url-shortener-demo-app cd url-shortener-demo-app mkdir server cd server npm init -y 
Enter fullscreen mode Exit fullscreen mode

Then, install all the required dependencies:

npm install fastify @fastify/cors mongoose ioredis zookeeper npm install --save-dev typescript @types/node ts-node 
Enter fullscreen mode Exit fullscreen mode

The packages you installed above include:

  • fastify: A simple web framework for handling routes and middleware.
  • @fastify/cors: Middleware for enabling Cross-Origin Resource Sharing (CORS).
  • zookeeper: A client for interacting with Apache ZooKeeper.
  • mongoose: An Object Data Modeling (ODM) library for MongoDB.
  • ioredis: A client for interacting with Redis.
  • And some devDependencies for Typescript support.

Next, open the package.json file and a in the scripts section a new script to run the application:

... "scripts": { "start": "ts-node src/index.ts" }, ... 
Enter fullscreen mode Exit fullscreen mode

And finally, create a tsconfig.json file for Typescript configuration by running:

tsc --init 
Enter fullscreen mode Exit fullscreen mode

Great, we can now move on and add the configuration files for MongoDB, Redis and ZooKeeper.

Setting Up MongoDB

Using mongoose in our application makes it easier to create and manage data within MongoDB, providing a more convenient and structured approach.

In src/config/mongoose.ts, add the following code to configure our MongoDB connection:

import { connect } from 'mongoose'; const { MONGODB_USER, MONGODB_PASSWORD, MONGODB_DATABASE, MONGODB_HOST, MONGODB_DOCKER_PORT, } = process.env; const MONGO_URI = `mongodb://${MONGODB_USER}:${MONGODB_PASSWORD}@${MONGODB_HOST}:${MONGODB_DOCKER_PORT}/${MONGODB_DATABASE}?authSource=admin`; // Connect to MongoDB export const connectToMongoDB = async (): Promise<void> => { try { await connect(MONGO_URI); console.log('Successfully connected to MongoDB'); } catch (error) { console.error('Error connecting to MongoDB:', error); throw error; } }; 
Enter fullscreen mode Exit fullscreen mode

We're loading some environment variables from the .env file in the root directory, so be sure to create it and add the following keys:

MONGODB_USER=user MONGODB_PASSWORD=pass MONGODB_DATABASE=urls MONGODB_HOST=mongo MONGODB_LOCAL_PORT=27017 MONGODB_DOCKER_PORT=27017 
Enter fullscreen mode Exit fullscreen mode

Then, create the URL data model, which will define the structure of our documents in the database. This model will include the following attributes:

  • originalUrl: The original URL that users want to shorten.
  • shortenUrlKey: A unique identifier generated for the shortened URL.
  • createdAt: A timestamp indicating when the URL was created.
  • expiresAt: A timestamp indicating when the shortened URL will expire.

In src/models/Url.ts, add the following code:

import { Document, Schema, model } from 'mongoose'; export interface IUrl extends Document { originalUrl: string; shortenUrlKey: string; createdAt: Date; expiresAt: Date; } const schema = new Schema<IUrl>({ originalUrl: { type: String, required: true, unique: true, }, shortenUrlKey: { type: String, required: true, unique: true, }, createdAt: { type: Date, default: new Date(), }, expiresAt: { type: Date, default: new Date(new Date().setMinutes(new Date().getMinutes() + 10)), // default is 10 minutes, for demonstration only }, }); export default model<IUrl>('url', schema); 
Enter fullscreen mode Exit fullscreen mode

Setting Up Redis

In the same way that we use mongoose to interact with our MongoDB database, we will use ioredis client to interact with Redis and manage frequently accessed URLs by leveraging caching mechanisms for faster retrieval.

We will override some of the initial Redis methods to:

  • Add a new entry in Redis cache with set().
  • Retrieve a Redis cache entry with get().
  • Extend TTL of an existing entry with extendTTL().

In src/config/redis.ts, add the following code:

import { Redis } from 'ioredis'; const { REDIS_HOST, REDIS_DOCKER_PORT } = process.env; export enum RedisExpirationMode { EX = 'EX', // Expire in seconds } let client: Redis | null; // Get Redis client const getRedisClient = (): Redis => { if (!client) { const config = { host: REDIS_HOST, port: Number(REDIS_DOCKER_PORT), maxRetriesPerRequest: null, }; client = new Redis(config); } return client; }; // Connect to Redis export const connectToRedis = async (): Promise<void> => { const client = getRedisClient(); client .on('connect', () => { console.log('Successfully connected to Redis'); }) .on('error', (error) => { console.error('Error on Redis:', error.message); }); }; // Set a key/value pair export const set = async ( key: string, value: string, expirationMode: RedisExpirationMode, seconds: number ): Promise<void> => { try { await getRedisClient().set(key, value, expirationMode, seconds); console.info(`Key ${key} created in Redis cache`); } catch (error) { console.error(`Failed to create key in Redis cache: ${error}`); } }; // Get a value from a key export const get = async (key: string): Promise<string | null> => { try { const value = await getRedisClient().get(key); console.info(`Value with key ${key} retrieved from Redis cache`); return value; } catch (error) { console.error( `Failed to retrieve value with key ${key} in Redis cache: ${error}` ); return null; } }; // Extend TTL of a key export const extendTTL = async ( key: string, additionalTimeInSeconds: number ) => { // Get the current TTL of the key const currentTTL = await getRedisClient().ttl(key); if (currentTTL > 0) { // Calculate the new TTL const newTTL = currentTTL + additionalTimeInSeconds; // Set the new TTL await getRedisClient().expire(key, newTTL); console.info(`TTL for key ${key} extended to ${newTTL} in Redis cache`); } else { console.error(`Failed to extend TTL of key ${key} in Redis cache`); } }; 
Enter fullscreen mode Exit fullscreen mode

Finally, add the following keys to the .env file to configure Redis service:

REDIS_HOST=redis REDIS_LOCAL_PORT=6379 REDIS_DOCKER_PORT=6379 
Enter fullscreen mode Exit fullscreen mode

Setting Up ZooKeeper

Apache ZooKeeper will help us avoid race conditions by making sure that only one node generates a token at a time, maintaining data integrity.

One approach could be to assign each server registered to the ZooKeeper service a specific token range and generate a token within that range. However, I chose a simpler solution: checking if a token already exists under the /tokens path. If the token is not found, it will attempt to generate a new token repeatedly until it finds one that is available.

For instance, if /tokens/existingToken already exists, it will try again and register /tokens/newToken if available. In our example, we will use a token that is 6 characters long, which gives us around 69 billion possibilities (64^6). This should provide a comfortable buffer before we encounter any collisions in our demo app.

First, add a method to generate a base64 token in src/utils/index.ts:

import { randomBytes } from 'crypto'; export const generateBase64Token = (length: number): string => { const buffer = randomBytes(Math.ceil((length * 3) / 4)); // Generate enough random bytes return buffer .toString('base64') // Convert to Base64 .replace(/\+/g, '-') // URL-safe: replace + with - .replace(/\//g, '_') // URL-safe: replace / with _ .replace(/=+$/, '') // Remove padding .slice(0, length); // Ensure fixed length }; 
Enter fullscreen mode Exit fullscreen mode

Next, in src/config/zookeeper.ts, add the following code to:

  • Connect the client to ZooKeeper.
  • Create the /tokens node if it doesn't exist.
  • Generate the unique token using the generateBase64Token() method we just created.
import ZooKeeper from 'zookeeper'; import { generateBase64Token } from '../utils'; const { ZOOKEEPER_HOST, ZOOKEEPER_DOCKER_PORT } = process.env; const host = `${ZOOKEEPER_HOST}:${ZOOKEEPER_DOCKER_PORT}`; let client: ZooKeeper | null; const TOKENS_NODE_PATH = '/tokens'; const MAX_RETRIES = 3; const MAX_TOKEN_SIZE = 6; // Get ZooKeeper client const getZookeeperClient = (): ZooKeeper => { if (!client) { const config = { connect: host, timeout: 5000, debug_level: ZooKeeper.constants.ZOO_LOG_LEVEL_WARN, host_order_deterministic: false, }; client = new ZooKeeper(config); } return client; }; // Connect to ZooKeeper export const connectToZookeeper = async (): Promise<void> => { const client = getZookeeperClient(); await new Promise<void>((resolve, reject) => { client.connect(client.config, async (error) => { if (error) { console.error('Error connecting to ZooKeeper:', error); reject(); } console.log('Successfully connected to ZooKeeper'); await createTokensNode(); resolve(); }); }); }; // Create '/tokens' node if it doesn't exist const createTokensNode = async (): Promise<void> => { const client = getZookeeperClient(); const doesTokensNodeExist = await client.pathExists(TOKENS_NODE_PATH, false); // If it does, do nothing if (doesTokensNodeExist) { console.info(`Tokens node ${TOKENS_NODE_PATH} already exists`); return; } // If it doesn't exist, create the root path await new Promise<void>((resolve, reject) => { client.mkdirp(TOKENS_NODE_PATH, (error) => { if (error) { console.error(`Failed to create tokens node: ${error}`); reject(); } console.info(`Tokens node ${TOKENS_NODE_PATH} created`); resolve(); }); }); }; // Create a node const createNode = async (path: string, data: Buffer): Promise<void> => { try { await getZookeeperClient().create( path, data, ZooKeeper.constants.ZOO_EPHEMERAL ); console.info(`Node ${path} created`); } catch (error) { console.error(`Failed to create node: ${error}`); throw error; } }; // Generate a unique token with retries for collision detection export const generateUniqueToken = async (retryCount = 0): Promise<string> => { const client = getZookeeperClient(); const token = generateBase64Token(MAX_TOKEN_SIZE); const uniqueTokenPath = `${TOKENS_NODE_PATH}/${token}`; // Create a child node with the generated token try { // Check if the unique token node already exists const doesUniqueTokenNodeExist = await client.pathExists( uniqueTokenPath, false ); // If it does, retry if (doesUniqueTokenNodeExist) { if (retryCount < MAX_RETRIES) { console.log( `Token collision detected for path: ${uniqueTokenPath}. Retrying... Attempt ${ retryCount + 1 } of ${MAX_RETRIES}` ); return await generateUniqueToken(retryCount + 1); } else { throw new Error( `Failed to generate a unique token after ${MAX_RETRIES} attempts due to collisions.` ); } } // If it doesn't exist, create the node await createNode(uniqueTokenPath, Buffer.from(token)); return token; // Return the unique token on success } catch (error) { console.error(`Error generating the unique token node: ${error}`); throw error; } }; 
Enter fullscreen mode Exit fullscreen mode

And finally, add the following keys to the .env file to configure the service:

ZOOKEEPER_HOST=zookeeper ZOOKEEPER_LOCAL_PORT=2181 ZOOKEEPER_DOCKER_PORT=2181 
Enter fullscreen mode Exit fullscreen mode

Great, we have all of our configuration files now ready! We can move on and start implementing the actual URL logic.

Implementing the URL Shortening Logic

In this section, we will walk through the process of implementing the logic needed to shorten a URL and manage the redirection when users access the shortened link.

Implementing URL repository

The URL repository will handle all database operations for managing shortened URLs:

  • Add a new URL to the database with create().
  • Retrieve all saved URLs with findAll().
  • Fetch a specific URL based on given parameters with findOne().

Add the following code in src/repositories/urlRepository.ts:

import Url, { IUrl } from '../models/Url'; interface ICreateParams { shortenUrlKey: string; originalUrl: string; } interface IFindOneParams { shortenUrlKey?: string; originalUrl?: string; } // Create a shortened URL const create = async (params: ICreateParams): Promise<IUrl> => { console.log(`Creating URL with params: ${JSON.stringify(params)}`); const result: IUrl = await Url.create({ ...params }); console.log(`Created URL: ${JSON.stringify(result)}`); return result; }; // Find all URLs const findAll = async (): Promise<IUrl[]> => { console.log('Finding all URLs'); const result: IUrl[] = await Url.find(); console.log(`Found URLs: ${result?.length || 0}`); return result; }; // Find a specific URL const findOne = async (params: IFindOneParams): Promise<IUrl | null> => { console.log(`Finding one URL with params: ${JSON.stringify(params)}`); const result: IUrl | null = await Url.findOne({ ...params }); console.log(`Found URL: ${JSON.stringify(result)}`); return result; }; export { create, findAll, findOne }; 
Enter fullscreen mode Exit fullscreen mode

Implementing URL validation

Before shortening a URL, it's essential to verify that the input provided by the user is valid. We will use a simple Regex found online for it (you can find plenty of other patterns as well depending on your needs).

Add the following method to src/utils/index.ts:

... export const isValidUrl = (value: string): boolean => { const pattern: RegExp = new RegExp( '^https?:\\/\\/' + // Protocol (http or https) '(?:www\\.)?' + // Optional www. '[-a-zA-Z0-9@:%._\\+~#=]{1,256}' + // Domain name characters '\\.[a-zA-Z0-9()]{1,6}\\b' + // Top-level domain '(?:[-a-zA-Z0-9()@:%_\\+.~#?&\\/=]*)$', // Optional query string 'i' // Case-insensitive flag ); return pattern.test(value); }; 
Enter fullscreen mode Exit fullscreen mode

Implementing Service Logic

The service layer is responsible for orchestrating the business logic of the URL shortener application, acting as an intermediary between the controller and the repository we just created.

As mentioned in the Redis section, we will leverage Redis for caching frequently accessed URLs to improve performance by reducing database queries.

In src/services/urlService.ts, add the following code:

import { generateUniqueToken } from '../config/zookeeper'; import { get, set, extendTTL, RedisExpirationMode } from '../config/redis'; import { IUrl } from '../models/Url'; import { isValidUrl } from '../utils'; import { create, findAll, findOne } from '../repositories/urlsRepository'; const ONE_MINUTE_IN_SECONDS = 60; // Get all shortened URLs export const getAllUrls = async (): Promise<IUrl[]> => await findAll(); // Get a specific shortened URL by its key export const getUrlByShortenUrlKey = async ( shortenUrlKey: string ): Promise<string | null> => { // Try to get the original URL from Redis cache const cachedOriginalUrl = await get(shortenUrlKey); if (cachedOriginalUrl) { // Extend TTL await extendTTL(shortenUrlKey, ONE_MINUTE_IN_SECONDS); return cachedOriginalUrl; // Return the cached original URL } // If not in cache, retrieve from database const savedUrl = await findOne({ shortenUrlKey }); if (savedUrl) { // Cache the original URL created by its shorten URL key await set( savedUrl.shortenUrlKey, savedUrl.originalUrl, RedisExpirationMode.EX, ONE_MINUTE_IN_SECONDS ); return savedUrl.originalUrl; // Return the saved original URL } return null; // Return null if nothing found }; // Create a new shortened URL export const createShortenedUrl = async ( originalUrl: string ): Promise<string | null> => { // Check if URL is valid if (!isValidUrl(originalUrl)) { return null; } // Retrieve from database const savedUrl = await findOne({ originalUrl }); if (savedUrl) { return savedUrl.shortenUrlKey; // Return the saved shortened URL key } // If not in database, generate a new shortened URL key and save it const shortenUrlKey = await generateUniqueToken(); if (shortenUrlKey) { const newUrl = await create({ originalUrl, shortenUrlKey, }); // Cache the original URL created by its shorten URL key await set( newUrl.shortenUrlKey, newUrl.originalUrl, RedisExpirationMode.EX, ONE_MINUTE_IN_SECONDS ); return newUrl.shortenUrlKey; // Return shortened URL key } return null; // Return null if token generation failed }; 
Enter fullscreen mode Exit fullscreen mode

Implementing Controller Logic

Next, let's implement the URL controller methods to manage the HTTP status codes and return appropriate messages for our operations. As you might have seen, I chose to return a 200 status code along with the original URL (and not a 301 redirect) in the getUrl() method to prevent any CORS issues between the client and the requested URLs later on.

In src/controllers/urlController.ts, add the following code:

import { FastifyReply, FastifyRequest } from 'fastify'; import { createShortenedUrl, getAllUrls, getUrlByShortenUrlKey, } from '../services/urlsService'; // Get all shortened URLs export const getUrls = async ( _request: FastifyRequest, reply: FastifyReply ): Promise<void> => { try { const urls = await getAllUrls(); return reply.code(200).send(urls); } catch (error) { return reply .code(500) .send('Failed to retrieve the list of URLs. Please try again later'); } }; // Get a specific URL by its key export const getUrl = async ( request: FastifyRequest<{ Params: { shortenUrlKey: string; }; }>, reply: FastifyReply ): Promise<void> => { try { const { shortenUrlKey } = request.params; const originalUrl = await getUrlByShortenUrlKey(shortenUrlKey); if (!originalUrl) { return reply .code(404) .send('The requested shortened URL could not be found'); } return reply.code(200).send(originalUrl); } catch (error) { return reply.code(500).send('Unable to retrieve the specified URL'); } }; // Create a new shortened URL export const postUrl = async ( request: FastifyRequest<{ Body: { originalUrl: string; }; }>, reply: FastifyReply ): Promise<void> => { try { const { originalUrl } = request.body; const shortenUrlKey = await createShortenedUrl(originalUrl); if (!shortenUrlKey) { return reply.code(400).send('The provided URL is invalid'); } return reply.code(201).send(shortenUrlKey); } catch (error) { return reply.code(500).send('Failed to create a shortened URL'); } }; 
Enter fullscreen mode Exit fullscreen mode

Implementing Routing Logic

Now, let's register the routes under the /urls prefix.

In src/routes/urls.ts, add the following code:

import { FastifyInstance } from 'fastify'; import { postUrl, getUrls, getUrl } from '../controllers/urlsController'; export const urlsRoutes = async (fastify: FastifyInstance) => { fastify.register( async (router: FastifyInstance) => { // Get all shortened URLs router.get('/', getUrls); // Get a specific URL by its key router.get('/:shortenUrlKey', getUrl); // Create a new shortened URL router.post('/', postUrl); }, { prefix: '/urls' } ); }; 
Enter fullscreen mode Exit fullscreen mode

Setting Up The Server

And finally, let's create a src/index.ts to set up the Fastify server and:

  • Configure CORS.
  • Register the URL router under the /api prefix.
  • Connect MongoDB, Redis, and ZooKeeper.
  • Start the server.
import Fastify, { FastifyInstance } from 'fastify'; import fastifyCors from '@fastify/cors'; import { connectToMongoDB } from './config/mongoose'; import { connectToRedis } from './config/redis'; import { connectToZookeeper } from './config/zookeeper'; import { urlsRoutes } from './routes/urlsRoutes'; // Fastify server instance const fastify = Fastify(); // Configure server fastify .register(fastifyCors) // Register CORS .register( async (fastify: FastifyInstance) => { fastify.register(urlsRoutes); // Register URL routes }, { prefix: '/api' } ); // Start the server const start = async () => { try { // Connect to MongoDB, Redis and ZooKeeper await connectToMongoDB(); await connectToRedis(); await connectToZookeeper(); // Start Fastify server await fastify.listen({ port: Number(process.env.NODE_SERVER_LOCAL_PORT), host: process.env.NODE_SERVER_HOST, }); console.log('Server is now listening'); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } }; start(); 
Enter fullscreen mode Exit fullscreen mode

Don't forget to add the following keys to the .env file:

NODE_SERVER_HOST=0.0.0.0 NODE_SERVER_LOCAL_PORT=3000 
Enter fullscreen mode Exit fullscreen mode

Awesome, our server is now ready! We can move on and configure Nginx.

Setting Up Nginx

As mentioned in the introduction, we will use Nginx as a load balancer and reverse proxy to distribute traffic across server instances and improve response times. We will use the default round-robin algorithm, which is ideal for distributing requests evenly and making our application more resilient.

In a nginx/nginx.conf file, add the following configuration:

  • Create an upstream node_servers block to define the group of servers listening on local port 3000. You will see later in the docker-compose setup that we did not define a specific port for each server instance, allowing Docker to dynamically assign ports and manage load balancing.
  • Add a server block which listens on port 80 for incoming requests.
  • Include a location block to forward requests made on the /api/ path to the node_servers group using proxy_pass. And add proxy_set_header directives to ensure that client request details are forwarded to the servers.
upstream node_servers { server server:$NODE_SERVER_LOCAL_PORT; } server { listen $NGINX_DOCKER_PORT; # Serve backend location /api/ { proxy_pass http://node_servers/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Port $server_port; } } 
Enter fullscreen mode Exit fullscreen mode

Also add the following keys to the .env file to configure the Nginx service:

NGINX_HOST=localhost NGINX_LOCAL_PORT=80 NGINX_DOCKER_PORT=80 
Enter fullscreen mode Exit fullscreen mode

Containerization with Docker

With everything set up, we will now use Docker to containerize all of our services.

Let's first dockerize the server application. Create a Dockerfile in the /server folder that sets up the Node environment, installs dependencies, and starts the app like below:

# Use an official Node runtime as the base image FROM node:22.11.0 # Set the working directory WORKDIR /usr/src/server # Copy package.json and package-lock.json to the container COPY package*.json ./ # Install application dependencies RUN npm install # Copy the rest of the application code COPY . . # Run the application CMD [ "npm", "run", "start" ] 
Enter fullscreen mode Exit fullscreen mode

Also add a .dockerignore file with node_modules inside because some libraries like zookeeper can cause some issues when compiled on different OS.

Next, create a Dockerfile in the nginx folder to configure Nginx by using the official Nginx image, copying our custom nginx.conf file to the container, and running the service:

# Use an official Nginx as the base image FROM nginx:stable-alpine # Copy nginx.conf to the container COPY nginx.conf /etc/nginx/templates/default.conf.template # Run the server CMD ["nginx", "-g", "daemon off;"] 
Enter fullscreen mode Exit fullscreen mode

Finally, create a docker-compose.yml file at the root of your project setting up the services (MongoDB, Redis and ZooKeeper), while building the server and Nginx from their respective Dockerfiles:

services: mongo: image: mongo:latest environment: - MONGO_INITDB_ROOT_USERNAME=${MONGODB_USER} - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_PASSWORD} ports: - ${MONGODB_LOCAL_PORT}:${MONGODB_DOCKER_PORT} volumes: - ./mongodb:/data/db redis: image: redis:latest ports: - ${REDIS_LOCAL_PORT}:${REDIS_DOCKER_PORT} zookeeper: image: zookeeper:latest ports: - ${ZOOKEEPER_LOCAL_PORT}:${ZOOKEEPER_DOCKER_PORT} server: depends_on: - mongo - redis - zookeeper environment: - MONGODB_USER=${MONGODB_USER} - MONGODB_PASSWORD=${MONGODB_PASSWORD} - MONGODB_DATABASE=${MONGODB_DATABASE} - MONGODB_HOST=${MONGODB_HOST} - MONGODB_DOCKER_PORT=${MONGODB_DOCKER_PORT} - REDIS_HOST=${REDIS_HOST} - REDIS_DOCKER_PORT=${REDIS_DOCKER_PORT} - ZOOKEEPER_HOST=${ZOOKEEPER_HOST} - ZOOKEEPER_DOCKER_PORT=${ZOOKEEPER_DOCKER_PORT} - NODE_SERVER_HOST=${NODE_SERVER_HOST} - NODE_SERVER_LOCAL_PORT=${NODE_SERVER_LOCAL_PORT} build: context: ./server dockerfile: Dockerfile volumes: - ./server:/usr/src/server - /usr/src/server/node_modules deploy: mode: replicated replicas: 3 nginx: depends_on: - server environment: - NODE_SERVER_LOCAL_PORT=${NODE_SERVER_LOCAL_PORT} - NGINX_DOCKER_PORT=${NGINX_DOCKER_PORT} build: context: ./nginx dockerfile: Dockerfile ports: - ${NGINX_LOCAL_PORT}:${NGINX_DOCKER_PORT} 
Enter fullscreen mode Exit fullscreen mode

As you can see, we are passing down all the environment variables defined in the .env file, keeping all configurations centralized in one place, which makes adjustments simple.

Testing and Deployment

Run docker compose up -d to start all containers in detached mode. Once they're running, you can use cURL or any other tool to test the API routes:

  • Save a new URL (you can also test an incorrect URL format to check if the service returns a 400 status code):
curl --location 'http://localhost/api/urls' \ --header 'Content-Type: application/json' \ --data '{ "originalUrl": "URL_HERE" }' 
Enter fullscreen mode Exit fullscreen mode
  • Get all URLs:
curl --location 'http://localhost/api/urls' 
Enter fullscreen mode Exit fullscreen mode
  • Retrieve the original URL:
curl --location 'http://localhost/api/urls/SHORTENED_TOKEN_HERE' 
Enter fullscreen mode Exit fullscreen mode

Or use Postman which is more friendly:

Get all URLs in Postman

You can also check the logs in Docker to monitor the activity across the containers (I'm using Docker Desktop here):

Logs Docker Desktop

Conclusion

You have reached the end of the first part of this tutorial! I hope you enjoyed πŸ˜„

We learned how to build a scalable URL shortener application from scratch using Node.js, Redis, MongoDB, Apache ZooKeeper, Nginx and Docker. You can find the complete code for this project here.

In the second part, we will focus on developing the frontend of our application using React and RTK Query, allowing users to interact with our servers through a minimal UI.

And if you're interested in going further, check out my other repository here. In this version, I've added extra features like a visit counter and a purge system to clean all expired URLs, all managed through a task queue service.

Top comments (0)