DEV Community

Cover image for How to build a web app with multiple subdomains using Nginx
Matt Angelosanto for LogRocket

Posted on • Originally published at blog.logrocket.com

How to build a web app with multiple subdomains using Nginx

Written by Daggie Douglas Mwangi✏️

Introduction

Atlassian, GitHub, Slack, and Zoom are some popular services that many of us use everyday. If you are a curious soul, I bet that you are fascinated by how these SaaS products issue out custom subdomains to their customers on the fly.

Let’s consider an example. When signing up for an Atlassian product, you get a subdomain for your company, like mycompany.atlassian.net; when you publish on GitHub Pages, you automatically get a subdomain, like myusername.github.io.

Im this guide, I will take you through the process of building a web app that supports multiple subdomains step-by-step, demystifying the parts that make it seem complex.

Before we get started, let's map out the journey, so you can refer back and forth as you go along.

  1. Technical requirements
  2. Terms and definitions
  3. Setting up our DNS
  4. Setting up Nginx
  5. Running Certbot commands
  6. Configuring Nginx for our SSL certificates
  7. Setting up our web app
  8. Starting our Nginx server

Technical requirements

Aside from the services you’ll need to follow this tutorial, it is worth mentioning that I will be using Linux Ubuntu v 20.04 in my server instance. You can use any OS of your choice.

This tutorial will require you to have:

A domain name that you own

For this article, I will use change.co.ke, and my domain registrar is KenyaWebExperts. You can purchase a domain name from any domain registrar of your choice, such as:

  • Enom
  • DynaDot
  • GoDaddy
  • Google Domains
  • Namecheap
  • Siteground

A cloud provider

You’ll need a cloud provider in order to deploy a server instance.

For this article, I will be using AWS EC 2, but you can use any cloud provider of your choice.

Here are some examples of other cloud providers besides AWS:

  • Azure
  • Google Cloud
  • Alibaba Cloud
  • IBM Cloud
  • Oracle
  • Salesforce
  • SAP
  • Rackspace Cloud
  • VMWare

A public IP address for your server

You should also have a public IP address for your server instance. For this article, my IP address is 3.143.148.31.

A DNS provider

Its purpose is to set up DNS records. For this article, I will be using Amazon's Route 53.

You can use any DNS provider of your choice, such as:

  • Akamai Edge
  • Alibaba Cloud DNS
  • Azure
  • Cloudflare
  • Google Cloud DNS
  • No-IP
  • Verisign Managed DNS
  • Oracle Cloud DNS

A database

For this article, I will be using MongoDB, hosted in MongoDB Atlas. You can use any database of your choice, provided that you can store and retrieve data.

Terms and definitions

To make our journey easier, here are the definitions of some terms you’ll need to know:

Domain Name System (DNS)

A naming system that is used for identifying IP networks over the internet where the domains and the IP addresses are unique identifiers for a network over the internet.

It works like the Contacts app on your phone, in that you can save people's phone numbers labelled with their names (assuming both the numbers and names are uniquely theirs).

A-records and wildcard domains

An A-record maps a domain (or a subdomain, or a wildcard domain) to an IP address. A wildcard domain is a record in the DNS zone that responds to requests for subdomains that have not previously been defined, usually defined by an asterisk prepended to the domain name, i.e.,*.logrocket.com.

Let’s say you request somerandomtext.logrocket.com, but the DNS server doesn’t recognize it. The DNS will attempt to check the value of the wildcard, and if *.logrocket.com maps to an A-record of 104.22.4.148, then any undefined subdomain of logrocket.com will be served by the resource whose IP address is 104.22.4.148.

Therefore, on requesting somerandomtext.logrocket.com, the DNS server will respond with the IP address 104.22.4.148.

Time to Live (TTL)

Time to Live is a time interval that specifies how long a DNS record should be cached by a DNS server. For example, if you set an A-record's TTL for 1 hour, then the DNS server will cache the A-record for one hour.

In the development stage, it is a good practice to set a low TTL so that you can quickly change the IP address of your server instance and see your changes without having to wait for for the TTL to expire, which can sometimes take a while. The lower the TTL, the shorter the DNS's propagation time, and vice versa.

At the production/live stage, you should set a high TTL so that you can avoid DNS latency issues.

TXT record

A TXT record is a record that maps a domain, subdomain, or wildcard domain to a text value. It is mostly used by external entities to prove that a person or organization indeed owns the domain they claim to.

Setting up our DNS

The first thing we need to do is point our domain name to our Nameservers. This is done by logging into the domain registrar's control panel, clicking the Nameservers tab, and adding the Nameservers issued to us by the DNS provider. My DNS provider (AWS Route 53) issued me the following Nameservers:

  • ns-1443.awsdns-52.org
  • ns-2028.awsdns-61.co.uk
  • ns-720.awsdns-26.net
  • ns-418.awsdns-52.com

How do I obtain a Nameserver?

The answer to this varies, depending on the DNS provider. I obtained mine by creating a Hosted Zone in Route 53. The specific instructions for Route 53 are out of scope for this article, but you can find them in the AWS documentation.

Below are screenshots of the DNS setup for this article.

Our Route53 setupScreenshot of our Route53 setup

Our domain registrar's DNS setup Our domain registrar's DNS setup

Next, we will add an A-record to our domain name. This is done by logging into the DNS provider's control panel and then adding our domain name to the DNS zone to resolve to the IP address of our Cloud Provider's deployed instance. Below is a screenshot showing this configuration on Route53. Take note of the domain name (change.co.ke), IP address (3.143.148.31), the TTL (10 seconds), and the record type (A-record).

Add an A-record to our Route53 configuration

Add another A-record

Next, we will add another A-record. This time it will be a wildcard domain. We can do this by logging into the DNS provider's control panel and adding a wildcard domain name to the DNS zone to resolve to the IP address of our cloud provider's deployed instance.

Below is a screenshot showing this configuration on Route 53. Take note of the domain name (*.change.co.ke), IP address (3.143.148.31), the TTL (10 seconds), and the record type (A-record).

[caption id="attachment_95247" align="aligncenter" width="730"]Add another A-record to Route53 Add another A-record to Route53[/caption]

Setting up Nginx

We’ve set up our domain name and DNS and we have our IP address. Now, we need to set up Nginx, a web server that sits on top of the TCP/IP stack. For our article, we will use Nginx as a reverse proxy.

Why is Nginx needed if Node.js can act as a server?

It is a good practice. Our EC2 instance exposes external ports 80 and 443 to the internet. If we were to use Node.js as a server, we would also have to open the internal ports 80 and 443 to the internet, too.

There is no problem with this, until we need to configure multiple Node servers on the same machine for load balancing purposes — not to mention how maintaining SSL certificates without Nginx can be a pain.

Alternatively, we can add more ports, but wouldn’t you find it ugly to tell your clients to use addresses like change.co.ke:3000 or change.co.ke:8080?

Run the following command in your terminal to install Nginx:

sudo apt-get install nginx
Enter fullscreen mode Exit fullscreen mode

Next, we will install SSL certificates for both our domain and our wildcard domain.

How do I install SSL certificates?

  1. First, visit https://certbot.eff.org/instructions The Certbot dashboard
  2. In the form, select the OS and distro you’re using. Mine reads: My HTTP website is running Nginx on Ubuntu 20
  3. Select the wildcard option Choose the wildcard domain option in Certbot

The form helps you to get the exact commands you need to run in the terminal for your specific OS. In my case, when using an Ubuntu 20.04 distro, the form recommends that I use the following commands:

First, install Snap by running the below commands in the terminal:

sudo snap install core; sudo snap refresh core
Enter fullscreen mode Exit fullscreen mode

Then install and prepare Certbot by running the below commands in the terminal:

sudo snap install --classic certbot
sudo ln -s /snap/bin/certbot /usr/bin/certbot
sudo snap set certbot trust-plugin-with-root=ok
Enter fullscreen mode Exit fullscreen mode

Now we are ready to run the Certbot commands.

Running Certbot commands

For the domain change.co.ke, run the command:

certbot certonly --manual --preferred-challenges=dns -d change.co.ke -i nginx 
Enter fullscreen mode Exit fullscreen mode

As seen in the screenshot below, the script will prompt you to add a TXT record to your DNS zone. In my case, I was prompted to add a TXT record of _acme-challenge.change.co.ke with its value as gC5ujO33YkuCCbNN2lv3TN0ugVxDgHBBrtBGyr0yq_Q.

The screenshot below shows the output of the command. The output of our Certbot command

To add this value, I logged into the DNS provider's control panel (i.e., Route 53) and added the TXT record, as shown in the screenshot below.

Take note of the record name (_acme-challenge.change.co.ke), the value (gC5ujO33YkuCCbNN2lv3TN0ugVxDgHBBrtBGyr0yq_Q), the record type (TXT-record), and the TTL (10seconds). Add the TXT record to our DNS provider

After adding the TXT record in your DNS zone, go back to the terminal and hit Enter.

Your SSL certificate for your root domain has been created. The response when you run the command will show you where the certificate has been stored in your OS's filesystem. In my case, the locations were:

1. Certificate for change.co.ke is saved at: /etc/letsencrypt/live/change.co.ke/fullchain.pem
2. Key for change.co.ke is saved at:         /etc/letsencrypt/live/change.co.ke/privkey.pem
Enter fullscreen mode Exit fullscreen mode

For the wildcard domain *.change.co.ke, run the command:

certbot certonly --manual --preferred-challenges=dns -d *.change.co.ke -i nginx
Enter fullscreen mode Exit fullscreen mode

The procedure that follows is similar to the one for the domain change.co.ke above; the only difference is the value of the TXT record. Add this value to the TXT record in your DNS zone. Then, go back to the terminal and hit Enter.

As seen in the screenshot below, the wildcard's certificate has been stored in the following locations:

1. Certificate for *.change.co.ke is saved at: /etc/letsencrypt/live/change.co.ke-0001/fullchain.pem
2. Key for *.change.co.ke is saved at:         /etc/letsencrypt/live/change.co.ke-0001/privkey.pem
Enter fullscreen mode Exit fullscreen mode

See the location in which our wildcard certificate was installed

At this point, we have our SSL certificates installed. We can now configure Nginx to use these certificates.

Configuring Nginx for our SSL certificates

In Ubuntu, the Nginx configuration file is located at /etc/nginx/sites-available/default. We will edit this file by running sudo nano /etc/nginx/sites-available/default.

First, clear everything inside this file and add the following lines:

# For use in /etc/nginx/sites-available/default

# This directive redirects all(All is denoted by a dot prefix on the domain) HTTP requests of change.co.ke and *.change.co.ke to their HTTPS versions respectively.
server {
  listen 80;
  listen [::]:80;
  server_name .change.co.ke;

  return 301 https://$server_name$request_uri;
}

# This directive tells Nginx to use HTTP2 and SSL. And also proxy requests of https://change.co.ke to a local Node.js app running on port 9000
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
  server_name change.co.ke;

  ssl_certificate /etc/letsencrypt/live/change.co.ke/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/change.co.ke/privkey.pem;
  ssl_session_timeout 5m;

  location / {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-NginX-Proxy true;
    proxy_pass http://localhost:9000/;
    proxy_ssl_session_reuse off;
    proxy_set_header Host $http_host;
    proxy_cache_bypass $http_upgrade;
    proxy_redirect off;
  }
}

# This directive tells Nginx to use HTTP2 and SSL. And also proxy requests of wildcard *.change.co.ke (first level subdomain of change.co.ke) to a local Node.js app running on port 9000
server {
    listen 443 ssl http2;
    listen [::]:443 ssl http2;
  server_name *.change.co.ke;

  ssl_certificate /etc/letsencrypt/live/change.co.ke-0001/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/change.co.ke-0001/privkey.pem;
  ssl_session_timeout 5m;

  location / {
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-NginX-Proxy true;
    proxy_pass http://localhost:9000/;
    proxy_ssl_session_reuse off;
    proxy_set_header Host $http_host;
    proxy_cache_bypass $http_upgrade;
    proxy_redirect off;
  }
}
Enter fullscreen mode Exit fullscreen mode

Setting up our web app

The most important part of this whole setup is the networking bits, and now we’re done with them! You can now comfortably use any tech stack to build your web app. I’ll be using the MERN stack for this tutorial, which includes:

  • Node.js
  • Express.js
  • EJS for templating
  • MongoDB

To keep things simple, our web app will be a simple CRUD application that will allow us to create a user and assign them a unique subdomain. If we browse a subdomain that has been assigned to a user, we will see the information of that user. If we try to browse a subdomain that has not been assigned to a user, we will get an error message.

The screenshot below shows the directory structure of our web app. Our demo app's folder structure

First, let’s create a directory for our Node app; I’ll call mine webapp. Then I will cd into this directory and run the following command:

>npm init --yes
Enter fullscreen mode Exit fullscreen mode

Next, I will install the required packages:

npm install ejs express mongoose signale vhost mongoose-unique-validator --save
Enter fullscreen mode Exit fullscreen mode

The package vhost is used to create virtual hosts. We will use this package to create virtual hosts for our web app to separate the subdomains from the root domain.

Next, we will create a file ./.env.js, which will contain the environment variables that we need to connect to our MongoDB database. It will also contain the port that we will use to run our Node app and the domain that we will use to create virtual hosts.

You should replace the values of the MONGODB_URI to your MongoDB Atlas URI and DOMAIN to your domain name. The code in this file will look like this:

module.exports = {
  ...process.env,
  MONGODB_URI: 'mongodb+srv://dbuser:dbpassword@cluster0.5wt57d.mongodb.net/tutorial?retryWrites=true&w=majority',
  DOMAIN: 'change.co.ke',
  PORT: 9000  
};
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./app.js. This file will contain the code for connecting to our MongoDB database and running our Express app on port 9000. This file will also contain the code that splits traffic between the root domain and the subdomains, depending on the user's request.

Since both the root domain and the subdomains will be served through the same Express app, the vhost package will be used to split traffic between the root domain and the subdomains. The code in this file will look like this:

process.env = require('./.env.js');
const express = require('express');
const path = require('path');
const mongoose = require('mongoose');
const vhost = require('vhost');

const rootDomainRoutes = require('./routes/rootdomain_route.js');
const subDomainRoutes = require('./routes/subdomain_route.js');

const main = async () => {
    const app = express();
    const port = process.env.PORT;

    const db = await mongoose.connect(process.env.MONGODB_URI);
    console.log('Connected to MongoDB ' + db.connection.name);

    // view engine setup
    app.set('views', path.join(__dirname, 'views'));
    app.set('view engine', 'ejs');

    app.use(express.json());
    app.use(express.urlencoded({ extended: false }));

    app.use(vhost(process.env.DOMAIN, rootDomainRoutes))
        .use(vhost('www.' + process.env.DOMAIN, rootDomainRoutes))
        .use(vhost('*.' + process.env.DOMAIN, subDomainRoutes));

    // error handler
    app.use(function (err, req, res) {
        res.status(404).render('error', {
            title: 'Error',
            Domain: process.env.DOMAIN,
        });
    });

    app.listen(port, () => console.log('App now listening on port ' + port));

    return app;
};

main()
    .then(() => console.log('App is running'))
    .catch((err) => console.log({ err }));
Enter fullscreen mode Exit fullscreen mode

The part of the code .use(vhost('www.' + process.env.DOMAIN, rootDomainRoutes)) simply tells Node to consider the www.change.co.ke subdomain as part of the root domain. Without this line, Node would consider www.change.co.ke as an undefined subdomain, and therefore would have handled it in the wildcard.

Next, we’ll create a file called ./routes/rootdomain_route.js. This file will contain the code for the routes of the root domain. The code in this file will look like this:

const express = require('express');
const router = express.Router();
const User = require('../models/user.js');

router.get('/', async (req, res, next) => {
    var allUsers = await User.find({});

    return res.render('rootdomain', {
        title: 'Accessing: ' + req.vhost.hostname,
        allUsers: allUsers.map((user) => {
            return {
                ...user._doc,
                link: 'https://' + user.link,
                fullname: user.fullname,
            };
        }),
    });
});

router.post('/', async (req, res) => {
    try {
        let data = {
            email: req.body.email,
            username: req.body.username,
            firstname: req.body.firstname,
            lastname: req.body.lastname,
        };

        var user = new User(data);
        await user.save();
        return res.redirect('/');
    } catch (error) {
        return res.json({ ...error });
    }
});
module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./routes/subdomain_route.js. This file will contain the code specific to a requested subdomain. The code in this file will look like this:

const express = require('express');
const router = express.Router();
const User = require('../models/user.js');

router.use(async (req, res, next) => {
    var users = await User.find({});

    users.forEach((user) => {
        if (user.link.indexOf(req.headers.host) > -1) {
            res.profile = {
                ...user._doc,
                link: 'https://' + user.link,
                fullname: user.fullname,
            };
        }
    });

    next();
});

router.get('/', (req, res, next) => {
    if (res.profile) {
        return res.render('subdomain', {
            subdomain: req.vhost.hostname,
            profile: res.profile,
            title: 'Accessing: ' + req.vhost.hostname,
        });
    } else {
        return res.render('subdomain', {
            subdomain: req.vhost.hostname,
            profile: null,
            title: 'Invalid: ' + req.vhost.hostname,
            create_subdomain_link: 'https://' + process.env.DOMAIN,
        });
    }
});

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./models/user.js. This file will be used to create a MongoDB collection called users, which we’ll then use to store the data of the users. The code in this file will look like this:

const Mongoose = require('mongoose');
const uniqueValidator = require('mongoose-unique-validator');

const UserSchema = new Mongoose.Schema({
    firstname: { type: String },
    lastname: { type: String },
    email: {
        type: String,
        require: true,
        unique: true,
        uniqueCaseInsensitive: true,
    },
    username: {
        type: String,
        require: true,
        unique: true,
        uniqueCaseInsensitive: true,
    },
});

UserSchema.plugin(uniqueValidator);

UserSchema.virtual('fullname').get(function () {
    return this.firstname + ' ' + this.lastname;
});

UserSchema.virtual('link').get(function () {
    return this.username + '.' + process.env.DOMAIN;
});

module.exports = Mongoose.model('User', UserSchema);
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./views/partials/header.ejs. This file will be used to render the header of every page. The code in this file will look like this:

<!DOCTYPE html>
<html>
  <head>
    <title><%= title %></title>
  </head>
  <body>
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./views/partials/footer.ejs. This file will be used to render the footer of every page. The code in this file will look like this:

 </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./views/rootdomain.ejs. This file will be used to render the root domain page. The code in this file will look like this:

><%- include('./partials/header.ejs') %>
<h2><%= title %></h2>

<div id="main">
  <div id="new">
    <form method="POST" action="/">
      <h3>Create a new subdomain</h3>
      First Name: <input type="text" name="firstname"><br>
      Last Name: <input type="text" name="lastname"><br>
      Email: <input type="email" name="email"><br>
      Username: <input type="text" name="username"><br>
      <input type="submit" value="Signup">
    </form>
  </div>

  <div id="list">
    <% if(allUsers.length){ %> 
      <p>List of registered users and their Subdomains</p>

      <table>
        <thead>
          <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Email</th>
            <th>Username</th>
            <th>Subdomain</th>
          </tr>
        </thead>
        <tbody>
          <% 
            allUsers.map((user)=>{
          %>    
            <tr>
              <td>
                <%= user._id %>
              </td>
              <td>
                <%= user.fullname %>
              </td> 
              <td>
                <%= user.email %>
              </td>
              <td>
                <%= user.username %>
              </td>
              <td>
                <a href="<%= user.link %>"><%= user.link %></a>
              </td>
            </tr>
          <% })%>
        </tbody>
      </table>

    <% }else{ %>
      <p>No users have been registered</p>
    <% } %>
  </div>
</div>
<%- include('./partials/footer.ejs') %>
Enter fullscreen mode Exit fullscreen mode

Next, we will create a file called ./views/subdomain.ejs. This file will be used to render the subdomain page. The code in this file will look like this:

<%- include('./partials/header.ejs') %>

<h2><%= title %></h2>

<div id="main">

  <% if (profile) { %>
    <h3>This is the profile page for <%= profile.fullname %>.</h3>
    <p>Email: <%= profile.email %></p>
    <p>Username: <%= profile.username %></p>
    <p>Subdomain: <a href="<%= profile.link %>"><%= profile.link %></a></p> 

  <% }else{ %>
      <p>
          This is not a valid subdomain.
      </p>
      <p>
          <a href="<%= create_subdomain_link %>">Want this subdomain? Click to claim it now.</a>
      </p>
  <% } %>  

</div>
<%- include('./partials/footer.ejs') %>
Enter fullscreen mode Exit fullscreen mode

At this point, all of our essential files are in place. We are ready to start our server.

Starting our Nginx server

To start the server, we will run the following command:

node ./app.js
Enter fullscreen mode Exit fullscreen mode

If you have followed every above step, you should see the following:

  1. Browsing your root domain (https://change.co.ke, in my case) will display a page that looks like the screenshot below: View of our root domain during browsing
  2. Browsing a random, unregistered subdomain (e.g., https://somerandomtext.change.co.ke) will display a page that looks like the screenshot below: View of our webpage when visiting an invalid subdomain
  3. When you register a new user via the form displayed on the root domain’s page, you will be redirected to the root domain and see a list of all the registered users and their subdomains. An example of this is shown in the screenshot below How to register a new subdomain
  4. If you click the subdomain link, you will be redirected to the subdomain page that looks like the screenshot below If you access the subdomain again, you'll see a new page

If the above screenshots resemble what you see in your browser, congratulations! You have successfully understood the basics of the project.

Conclusion

In this tutorial, we have covered what is required for building a web app that supports multiple subdomains. You now have a solid foundation to build your web app that supports multiple customizable subdomains, good job!

I am looking forward to seeing what you build. Happy coding, and stay safe!


LogRocket: Full visibility into your web apps

LogRocket signup

LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page and mobile apps.

Try it for free.

Top comments (0)