DEV Community

Cover image for How to allow end-user custom domains in your app
Carter Bryden
Carter Bryden

Posted on

How to allow end-user custom domains in your app

Do you want to let your users connect their own domains or subdomains to your app, like Shopify lets you put your own domain on a shop?

This guide will teach you how to build that feature yourself.

How I know this: I've been building web services for over a decade, and I've built approximated.app for this exact purpose, which reliably serves 300k+ domains every month.

This guide is going to be pretty long. If you'd rather pay $10/month to have this all done for you, head over to approximated.app. If you'd prefer to build it yourself, then read on!

Why you might want custom domains

Lets start with a few example web apps:

  • A blogging platform that hosts and provides a UI for writing your own blog
  • A marketplace that lets you operate your own ecommerce shop
  • A payment provider that hosts invoices and checkout pages for you

Each of those can benefit a lot by allowing users to connect custom domains:

  • The blog users want to point their own domains at it and load their blog under something like cartersblog.com instead of wehostblogs.com/cartersblog for the SEO juice.
  • The marketplace users demand to use their own domains for their shops, like leshopdecarter.com instead of ecommercesite.com/leshopdecarter for the branding AND the SEO juice.
  • The payment provider users want to be able to have their invoices show up at invoices.theirapp.com instead of paymentprovider.com/invoices/theirapp for the branding but also because it feels less professional to send customers to someone else's domain.

There's real value to adding custom domains, and I would even go as far as to say that pretty much every web app/service can benefit from this in some way.

Even if your app doesn't provide services that your end users will have public facing, say a project management app, they still like it if they can point a domain/subdomain at it and brand it as their own.

Hot tip: especially for larger customers, custom domains are a great up-sell feature to help entice them towards business or enterprise plans. Having things under their own branding matters a lot to them and they're willing to pay for it.

A few terms to know, if you don't already

If you have a web app, you probably already know most of this, but some people use different terms and the language around this can get confusing quickly. Here are the important terms I'll be using:

  • Domain - the main part of a website address, like example.com or indiehackers.com. It doesn't include the https:// before it or the paths after it like /some/path
  • Subdomain - when you add something before the domain like this something.example.com, something is the subdomain. Most of the time when people refer to a subdomain, they're talking about the entire thing though. Fun fact: subdomains technically are a domain as well.
  • Apex Domain - the same thing as a domain, but specifically referring to it "naked" or with no subdomain attached. This is important because DNS (explained later) often treats these differently than subdomains in a few important ways.
  • Custom Domain - just a way to specifically refer to a domain owned by your end user instead of you, the owner of the web app. This is less of an official term and more just something most services have decided to use.
  • SSL/TLS certificates - these secure traffic to to a domain and they're both simple and complicated to understand at the same time. You'll want them though. I'll explain more further down.
  • Wildcard SSL/TLS certificates - secures traffic to any immediate subdomain of the domain it's issued for. A way to secure <any-subdomain>.yoursite.com with one certificate. Very useful if you have a lot of subdomains.

What you need to offer custom domains

There's a few different things you'll need at a minimum, depending on the level of sophistication you want/need for your use case.

I'll explain how to use most of these later, but the pieces are:

  • Reverse proxy - A reverse proxy is a bit of software that relays requests from users to a destination server. The most critical part of what we're doing. I recommend Caddy for reasons I'll get into a bit further on, and this guide focuses around it.
  • Hosting - if you've got a web app already you likely have hosting, but it may not be easy or possible to add Caddy to every hosting setup (say, Vercel, or other frontend focused providers). In that case, you'll need somewhere that can host the reverse proxy, though if your host is globally distributed like Vercel, you'll probably want a cluster of Caddy instances around the world. I'll explain more below.
  • Code in your app to manage custom domain configs - Basically your app needs to be able to update Caddy so that it knows about each custom domain, can provision an SSL certificate, and proxy requests to your app. Each custom domain can be proxied to different urls/ports.
  • Code in your app to handle requests for each custom domain - when a request comes in for a custom domain, your app likely needs to know how to respond appropriately for that specific domain. For example returning the right blog or shop.
  • Code in your app to monitor it all (optional) - It's very useful for both you and your customers to have something automatically checking to see if custom domains are pointed correctly, secured with SSL, and resolving properly. That way your users can see for themselves that, for instance, they still need to actually point their custom domain at your service. Or it can warn you of issues with things like SSL certificates (which can and does happen).
  • A DNS provider with an API (optional) - If you want to automate and offer secured wildcard SSL certificates, your reverse proxy will need to be able to update some DNS records for you on demand through your DNS provider's API. Caddy has a list of plugins that integrate with some supported DNS providers here

If that seems like a lot and at this point you'd like to just have it done for you, approximated.app will have you covered. If you'd still like to build this yourself, lets continue on.

How they work together

At a high level, the flow of this whole system works like this:

  1. Your user sets a custom domain in your app, say it's customdomain.com
  2. Your app updates the caddy config by communicating with the caddy instance, telling it to proxy this new domain and secure it.
  3. Your app tells the user to point their domain at your caddy instance, by updating their DNS for that domain.
  4. Once they have done that, requests for that domain will hit your caddy instance, which will generate and verify an SSL certificate on the first request for that domain to hit it.
  5. The caddy instance will take each request and proxy it to your app.
  6. Your app, knowing how to handle that custom domain, responds with content appropriate for it.
  7. That response is proxied back through caddy and returned to the user.
  8. Wildcard certs (optional) can be shared by all immediate subdomains for the domain they're attached to and only need to be generated once.
  9. Monitoring (optional) checks periodically to make sure the custom domain DNS is pointed, there are no SSL certificate issues, and that it resolves properly.

Hosting Caddy

First off, you'll need to decide how you want to run Caddy:

Option 1: Put it on the same server as your app

It's a reverse proxy, so you could just plunk it down next to your app on whatever server you're running and route all of the app traffic through it. If you're running a backend/fullstack framework like Laravel, Rails, Phoenix, etc. this might be the easiest method for you. If you already have a reverse proxy running, I'd recommend replacing it instead of stacking them (routing through one, then the other) unless you have a very good reason. Otherwise it just complicates things.

Option 2: Put it on another server

This increases the number of network hops but usually has a very minimal impact on latency or response time, provided it's somewhat near your app server. So why would you want to do this? Mainly when you don't have (or maybe want) full control of your backend. People using platforms like Vercel, Render, Firebase, etc. where you don't have the option of installing something like Caddy on the server side. Or alternatively, if aren't sure where all you might need to point to in the future. Say, if you offer a self hosted product but want to offer some kind of integrated custom domains service to whatever server they might be running.

A note on clustering

There are plenty of cases where you might want to run a whole cluster of Caddy instances, but the main scenario is:

  • You have multiple instances of your app server around the world already
  • Or, you use a platform like Vercel/Render/Firebase where it's deployed globally
  • And you have users spread out over different global regions

Now imagine a request from a user in Canada is sent to one of the custom domains connected to your app and you only have one Caddy instance in, let's say, France. That request is going to have to travel this route:

  1. Sends from Canada
  2. Goes through Caddy instance in France
  3. Routed to your nearest app server (yours or Vercel/Render/Firebase)
  4. Back to Caddy in France
  5. And finally back to the user

Not great, right? To solve this, you can run multiple instances of Caddy in different regions near your users and app servers, so that the route is always short. This can get pretty complicated though, because you'll need to keep their configurations up to date on every instance, and share SSL certs that get generated between them so you don't A) confuse browsers, and B) generate an SSL cert for the same domain multiple times and run into rate limits with certificate authorities that issue the certs.

This is a whole topic on it's own, so I won't go any deeper into it in this guide, but if you want really easy clustering, approximated.app will do it for you with a click.

Installing Caddy

Once you've decided where you're going to host Caddy, you'll need to actually go and get it on the server. There's a few ways you can do this, and the Caddy docs here make it pretty easy: https://caddyserver.com/docs/install. Generally I'd recommend the daemon/service official packages if you can. That way it'll be configured to restart if it ever crashes and on system startup. Plus it's usually easier to manage starting/stopping and logging that way.

After you have that installed (or the binary or a docker image with it), you can start it up. It'll have a very minimal config that it runs off of at the moment and do basically nothing until we configure it some more.

Initial Caddy Configuration

There's two main ways to configure Caddy - a config file it reads on startup, or it's admin API endpoint that defaults to localhost:2019 (docs: https://caddyserver.com/docs/api). I recommend, for a single Caddy instance, to use the admin API endpoint.

Warning: tripping point:
If you're using one of the official packages like the Debian one then it actually installs two services, one called caddy which starts up with a config file argument, and one called caddy-api which doesn't. Both services will auto-save your config to disk when it receives updates over the admin API, but the regular caddy service will overwrite it on restart with the original config file. To avoid this, run systemctl disable caddy then systemctl enable caddy-api (or your OS equivalent) to switch to caddy-api instead.

Regardless of how you're updating Caddy's config, you should make sure you're always able to recreate that config from scratch elswhere, like your app. Don't rely on Caddy's config autosave feature alone. At the very least back it up regularly.

Configuration Update Strategies

Now that you know how you can set the configuration in Caddy, you'll need to decide on a strategy for updating the configuration itself when custom domains are created/updated/removed in your app. They all have their own trade-offs, which one works best for you will depend on your situation.

A few options are:

  1. Use the admin API endpoint, and send a single update for each change.

    • This can be problematic if you end up having a lot of changes suddenly or frequently.
    • A config update can be resource intensive with larger numbers of custom domains, so if there's a lot at once, the server could choke on that for a bit or even time out.
    • You could also potentially have race conditions where one update happens before another unexpectedly with this method
  2. Use the admin API endpoint, and load the latest complete config on a schedule.

    • This avoids the too-many-updates-too-fast issue above.
    • But it does introduce a delay before the config is in place.
    • It's also a potentially expensive operation you'll be guaranteed to run every X amount of time, even if no changes have been made.
    • You may want to have some logic in the code that runs this to only run if there have been changes.
  3. Update a config file, tell Caddy to reload using that file when changes occur or on a schedule.

    • Similar to either of the two above, but using a file instead of the admin API.
    • This may be simpler for some situations, but you lose getting a response back from the admin API that could notify you of success or errors.

However you end up doing this, be sure that you don't accidentally open up the admin API to the public internet or something similar. Otherwise anyone who finds it (and their are bots looking for it) can control your Caddy instance, intercept requests, and generally do whatever they want with it. By default, Caddy's admin API is localhost only and has some sane defaults to prevent that, but I've also seen people make it public without considering the risk.

Custom Domain Configuration Basics

When you're ready to start updating the config with custom domains, you'll want to use Caddy's HTTP routes feature, with a reverse proxy handler.

Each route can be matched to almost anything in a request using the match fields. In this case, you likely want to match just on the host.

In the reverse proxy handler, you'll want to set your upstream to your app URL. In some cases, you might want to add some extra paths on there, but beware that can have some unexpected behavior because every request will be sent to that path as the base URL, instead of the naked domain as the base URL.

There are a ton of things you can set in the handler, each of which could be their own article, so I won't go over them here because they aren't absolutely required. But explore the docs to see what you might need!

The other thing you'll probably want to have is on-demand TLS turned on. You can see the docs for that here.

This will wait to attempt to grab an SSL/TLS cert until the first request hits the Caddy instance for a domain in the config, instead of trying on startup. That can be very handy when your user may not have pointed their DNS yet, since the certificate authorities have pretty strict rate limits you could run into trying to verify the domain.

If you decide to cluster, each instance inside the cluster will need to coordinate so that certificate authorities trying to verify a domain for SSL issuance aren't getting different responses from different instances. They use servers all over the world, so you can't guarantee it'll hit the same server that asked for the cert. That's outside the scope of this article, but it can be really hard to debug so I wanted to mention it quickly.

Finally, if you're using on-demand TLS, you'll need to create an Ask endpoint in your app that Caddy can hit to ensure that it should actually allow that domain. It's an extra security/rate limit protection measure to make sure you're not serving domains you don't intend to. You can find docs on that here.

Handling Requests for Custom Domains in your App

Okay, so lets say you've got a Caddy instance and it's automatically updating the configuration for custom domains, and now requests for that custom domain are secured and reaching your app.

It's probably not working yet (oh no!).

That's likely because your app doesn't know a few things yet. Most frameworks will have at least a few of these:

  • It's getting requests for domains that it's not configured for (probably only set to your app's primary domain), and blocking those by default.
  • Or your existing reverse proxy is doing something similar.
  • Even if it's not blocking those, it's probably either not sure what content to return, or returning the default root page of your app which you probably don't want.
  • It may have a few other things you need to sort out, like CORS settings that are set to one absolute domain. These vary a lot by framework, ask around those communities!

Generally, you need to figure out how to allow (as in, not block) domains other than the primary domain in your framework. Usually that can be done somewhere like a router, or with middleware, and often requires some config change to stop hard-coding it.

Once you've done that, you'll need to setup some route groups or middleware (or whatever equivalents your framework has) to separate out requests for the primary app vs requests for custom domains.

With Approximated, we've created a few example repos that you can use as an example for how to handle this in those frameworks:

Alternatively, you could have a separate app that handles only custom domain requests, but most folks have already built features to handle specific sub directories or subdomains so it's easier to do it all in one app.

You'll also want to let your user's know how to point their domain at your Caddy instance. Typically you'll want an A record or a CNAME record. Either is fine but each has trade-offs.

All Together, Now

Here's the list again of the things that you need and that we've covered in this guide:

  1. A server (or cluster) with Caddy installed
  2. A way to generate config updates from your app.
  3. A strategy to update the Caddy instance configuration.
  4. An 'Ask' endpoint in your app for Caddy to confirm custom domains.
  5. Custom domain request handling in your own app.
  6. Instructions for your users on how to point their DNS record.

Once that's all setup, you should be able to create custom domains, secure them with TLS/SSL certs on demand, and handle requests for them that return the appropriate responses for your app! Woohoo!

Some Potential Pitfalls to be Aware of

  • As the number of domains scale up, you may run into issues with configuration updates taking a fair bit of resources and time.
  • The same can be said for resources required in general, expect to increase resources with the domain count over time. This is different for every app and depends on concurrent connections, request and response sizes, your app's response times, etc.
  • You're going to run into some weird and obscure issues at some point, especially around SSL certs, because you'll be managing orders of magnitude more domains than most devs/companies. These can be hard to sort out because very few people have had to deal with them so far, and even fewer have written about it.
  • Sometimes things happen out of your control, especially around certs, because the basic pieces of the internet are fairly codependent. Things like cert authorities revoking millions of certs suddenly, or an ISP in the US midwest suddenly not forwarding packets for certain IP ranges for no particular reason, or a major hosting provider tangling your IP addresses up while mitigating someone else's DDOS attacks. These are actual things that have happened to us, and they can be really hard to sort out on your own. Usually you need someone on the inside to help you out.
  • People care a lot about their custom domain not being down. Companies demand it. They expect major hosting provider levels of uptime for low prices (or free). Please be aware of the support and maintenance burden you may be getting into when combined with the points above.
  • Related to that, it's a very good idea to have monitoring setup for your custom domains. A service like Approximated comes with that, but if you're doing it yourself, you'll want to check that the domain is resolving, it's reaching your Caddy instance, it's reaching your app, and that the certs are valid.

So that's everything for now! I'll update this as needed, but hopefully this can get you started on at least trying out custom domains as a feature. And again, if you'd rather not build this yourself, you can always use Approximated.

Top comments (0)