Published on October 11, 2023

Setting Up Vanity Subdomains for Your SaaS Using Next.js and Caddy

Setting Up Vanity Subdomains for Your SaaS Using Next.js and Caddy

So, you're building a SaaS, or perhaps contemplating the idea of building one. You've got a fantastic idea, and you're ready to get started. But wait, you want to offer vanity subdomains to your users, like status.yourdomain.com, and you have no idea how to do that. Well, you've come to the right place. In this blog post, I'm going to show you how to set up vanity subdomains for your SaaS using Caddy and provision SSL certificates on the fly using Let's Encrypt.

What is a Vanity Subdomain?

Let's say you're building an uptime-monitoring service for websites, and you create a cute little status page for your users. They get to choose a theme, and they can customize the page to their liking. Maybe you even offer them a custom subdomain such as acme.status.com, and it all works great for a while. But then, you get a new enterprise customer, and they want to use their domain for the status page because, you know, branding and stuff. So now you are faced with a problem: how do you allow your users to use their domain for their status page?

Well, that is what we call a vanity subdomain. It's a subdomain that your users create, and that points to your service. In our example, it would be status.acme.com. And that's what we're going to set up today.

What are the Options?

There are many ways to set up vanity subdomains for your SaaS. You could use a third-party service, such as Cloudflare, or pretty much any other DNS or PaaS provider. These are great options, but chances are you may be concerned about vendor lock-in, or you may want to have more control over your infrastructure, or at the very least, you may be curious about how you could do it yourself. And that's what we're going to do today.

Create a Web App

Alright, first and foremost, we need to have some sort of web app that is going to handle the requests for our vanity subdomains. For this example, I'm going to use a simple Next.js app with a single page at the index for now.

// pages/index.tsx
export default function Home() {
return (
<main>
<h1>Hello world!</h1>
</main>
);
}

I will then deploy this app to a VM using Docker and Docker Compose. This step is entirely up to you, and you can deploy your app however you want. Therefore, I will skip the details of this step.

At this point, we have our Next.js app running on a public IP address on port 3000. We can access it by going to http://[ip]:3000.

Perfect, now let's move on to the next step.

Setting Up Caddy

Caddy is a web server designed to be easy to use, and capable of handling a wide variety of use cases. It's also very easy to set up, it's fast, and most importantly, it makes it very easy to provision SSL certificates on the fly using Let's Encrypt. And that's exactly what we're going to use it for.

First, we need to install Caddy. You can find the installation instructions here.

Once you have Caddy installed, you can run the version command to make sure it's installed correctly.

caddy version

You should see something like this:

v2.7.4

Now, we need to create a Caddyfile. This is where we will configure Caddy to handle our requests. Create a file called Caddyfile in the root of your project, and add the following:

{
email <your-email>
}

:80 {
reverse_proxy localhost:3000
}

Let's break this down; The first line is the email address that will be used to provision SSL certificates using Let's Encrypt. The second line is the address and port that Caddy will listen on. In this case, we're listening on port 80, which is the default port for HTTP. The third line is where we configure Caddy to reverse proxy requests to our Next.js app running on port 3000. This means that when a request comes in, Caddy will forward it to our Next.js app, and then return the response to the client.

Now, we can run Caddy using the following command:

caddy run --config Caddyfile

Note: if Caddy is already running, you can use caddy reload --config Caddyfile to reload the configuration.

Now, if you go to http://[ip] you should see your Next.js app running.

To get a better understanding of how our setup works, let's take a look at a diagram:

Caddy

Setting Up Our Domain

Alright, now that we have Caddy running and it can forward requests to our Next.js app, we need to set up our domain. For this example, I'm going to use acme.com as an example.

Head to your DNS provider and create an A record for acme.com that points to the IP address of your server, and a CNAME record for www.acme.com that points to acme.com. Here's how your DNS records should look like:

TypeNameValue
Aacme.com[ip]
CNAMEwww.acme.comacme.com

Now, if you go to http://acme.com you should see your Next.js app running. However, if you go to https://acme.com you will get an error. This is because we haven't set up SSL yet. Let's do that now. Let's quickly fix that.

Setting Up SSL

Caddy makes it very easy to provision SSL certificates on the fly using Let's Encrypt. All we need to do is add the following to our Caddyfile:

{
email <your-email>
}

:80 {
reverse_proxy localhost:3000
}

acme.com {
reverse_proxy localhost:3000
}

Now, if you run caddy reload --config Caddyfile you should see that Caddy is trying to provision an SSL certificate for acme.com. If everything goes well, we should be able to access our app using https://acme.com.

Perfect! It's that easy to set up Caddy as a reverse proxy and provision SSL certificates on the fly using Let's Encrypt. Now, let's move on to the next step.

How We're Going to Set Up Vanity Subdomains

Right now, our website is up and running on acme.com, and we've got SSL certificates in place for acme.com. The next step is to create vanity subdomains for our users.

What we're planning to do is create a wildcard subdomain for our customers, like this: *.customer.acme.com. Then, our customers will create a CNAME record for their chosen vanity subdomain, which will point to *.customer.acme.com. For example, if we have a customer who uses example.com and they want status.example.com as their vanity subdomain, they will need to create a CNAME record for status.example.com that points to example.customer.acme.com.

This means that when someone requests status.example.com, it will resolve to example.customer.acme.com, which will then hit our server, and Caddy will forward it to our Next.js app, and then return the response to the client.

Setting Up Vanity Subdomains

Let's give this a try, let's create a wild card A record for *.customer.acme.com that points to the IP address of our server. Here's how your DNS records should look like:

TypeNameValue
Aacme.com[ip]
CNAMEwww.acme.comacme.com
A*.customer.acme.com[ip]

Now, our customers can create a CNAME record for their chosen vanity subdomain that points to *.customer.acme.com. Here's how their DNS records should look like:

TypeNameValue
CNAMEstatus.example.comexample.customer.acme.com

Now, if you go to http://status.example.com you should see your Next.js app running. However, if you go to https://status.example.com you will get an error. This is because we haven't set up SSL yet. And this is the core of the problem we're trying to solve today.

Provisioning SSL Certificates On-Demand Using Caddy

Caddy has a nice handy feature called on-demand TLS. This feature allows Caddy to provision SSL certificates on the fly using Let's Encrypt. This means that when a request comes in for a domain that doesn't have an SSL certificate, Caddy will automatically provision one for it.

A nice feature of on-demand TLS is that it requires us to define an endpoint that Caddy will send an HTTP request to ask if it has permission to obtain and manage a certificate for the domain in the handshake. This means that we have fine-grained control over which domains we want to allow to be used as vanity subdomains. And that's exactly what we're going to do.

Before we get to implementing this feature, here's a quick diagram of how it works:

On-demand TLS

To implement this, let's define an API endpoint in our Next.js app that will take a domain as a parameter, do some validation, and then return a response to Caddy. Here's how our Next.js app should look like:

// pages/api/validate.ts
import type { NextApiRequest, NextApiResponse } from "next";

export default function handler(req: NextApiRequest, res: NextApiResponse) {
const domain = req.query.domain;

// Do some validation here to make sure the domain is valid
// if the domain is valid, return a 200 response
// if the domain is not valid, return a 400 response

res.status(200).json({ ok: true });
}

Just to make sure everything is working as expected, let's try to access this endpoint using https://acme.com/api/validate?domain=example.com. You should see a response like this:

{
"ok": true
}

Now, let's update our Caddyfile to use on-demand TLS. Here's how our Caddyfile should look like:

{
email <your-email>
on_demand_tls {
ask https://acme.com/api/validate
}
}

:80 {
reverse_proxy localhost:3000
}

acme.com {
reverse_proxy localhost:3000
}

:443 {
tls {
on_demand
}
reverse_proxy localhost:3000
}

Let's review what we've done here. First, we've added an on_demand_tls block to our Caddyfile. This block defines the endpoint that Caddy will send an HTTP request to ask if it has permission to obtain and manage a certificate for the domain in the handshake. In our case, we're using https://acme.com/api/validate. This means that when a request comes in for a domain that doesn't have an SSL certificate, Caddy will send an HTTP request to https://acme.com/api/validate to ask if it has permission to obtain and manage a certificate for the domain in the handshake. If the response is 200, Caddy will provision an SSL certificate for the domain. If the response is anything else, Caddy will return an error.

Next, we've added a :443 block to our Caddyfile. This block defines the address and port that Caddy will listen on for HTTPS requests. In this case, we're listening on port 443, which is the default port for HTTPS. We've also added a tls block to our Caddyfile. This block defines the TLS configuration for the server. In this case, we're using on_demand to enable on-demand TLS.

Now, if you run caddy reload --config Caddyfile and go to https://status.example.com, Caddy will send an HTTP request to https://acme.com/api/validate to ask if it has permission to obtain and manage a certificate for status.example.com. If the response is 200, Caddy will provision an SSL certificate for status.example.com. If the response is anything else, Caddy will return an error.

The first time you access https://status.example.com, it will take a few seconds to provision the SSL certificate. However, if you try to access it again, it will be much faster because Caddy will use the cached certificate.

Dynamically Rendering Content Based on the Domain

So far, all of our requests have been handled by our Next.js app the same way regardless of the domain. In other words, we've been rendering the same content for all domains. However, we want to be able to render different content based on the domain. For example, if someone requests https://status.example.com, we want to render a status page for example.com. And if someone requests https://status.acme.com, we want to render a status page for acme.com.

To do this, we can simply use the request headers to get the domain, and then render the content based on the domain. For example, in our index page, we can use the getServerSideProps function to get the domain from the request headers, and then render the content based on the domain. Here's how our index page should look like:

// pages/index.tsx
import { GetServerSideProps } from "next";

export default function Home({ host }: { host: string }) {
return (
<main>
<h1>Hello world from {host}</h1>
</main>
);
}

export const getServerSideProps: GetServerSideProps = async (context) => {
const host = context.req.headers.host;

return {
props: {
host: host,
},
};
};

Now, if you go to https://acme.com you should see Hello world from acme.com, and if you go to https://status.example.com you should see Hello world from status.example.com.

You can use this technique to render different content based on the domain. For example, you can render a status page for example.com and a status page for acme.com.

URL Rewriting

To make things even better, we can use Caddy to rewrite the URL to make it look like the request is coming from the domain. For example, if someone requests https://status.example.com, we can rewrite the URL to https://acme.com/status/example.com. This will make it easier for us to render different content based on the domain.

To do this, we can use the rewrite directive in our Caddyfile. Here's how our Caddyfile should look like:

{
email <your-email>
on_demand_tls {
ask https://acme.com/api/validate
}
}

:80 {
reverse_proxy localhost:3000
}

acme.com {
reverse_proxy localhost:3000
}

:443 {
tls {
on_demand
}
handle /_next/* {
reverse_proxy localhost:3000
}
handle {
rewrite * /status/{labels.1}.{labels.0}
reverse_proxy localhost:3000
}
}

So, what we've done here is we've added a handle block to our Caddyfile. This block defines the request matcher. In the first line, we are excluding the /_next/* path from the rewrite rule. This is because we don't want to rewrite the URL for the static assets. In the second line, we are rewriting the URL to https://acme.com/status/example.com. This means that when someone requests https://status.example.com, Caddy will rewrite the URL to https://acme.com/status/example.com. And in the third line, we are forwarding the request to our Next.js app.

Building the Dynamic Status Page

Now that we are able to render different content based on the domain, we can build a dynamic status page for our customers. For example, we can render a status page for example.com and a status page for acme.com.

First, let's create our page, pages/status/[slug].tsx. Here's how our page should look like:

import { GetServerSideProps } from "next";

export default function Status({host}: { host: string }) {
return (
<main>
<h1>
Status page for {host}
</h1>
</main>
)
}

export const getServerSideProps: GetServerSideProps = (async (context) => {
const slug = context.params?.slug
return {
props: {
host: slug
}
}
})

Now, if you go to https://status.example.com you should see Status page for example.com, and this is because we're using the domain as the slug. We can then use the slug to render different content based on the domain.

Wrapping Up

And that's it! We've successfully set up vanity subdomains for our SaaS using Caddy and provisioned SSL certificates on the fly using Let's Encrypt. We've also built a dynamic status page for our customers.

Interested in LogSnag?

Get started right now for free!