SaaS Custom Domains with Caddy

Ravenna Kev
7 min readJun 15, 2021

TLDR — Caddy is a web server that can automatically manage TLS certificates on your behalf. In this post, I demonstrate how to add custom domain functionality to any SaaS application using Caddy.

I recently came across a new startup called Super on Twitter. Super allows customers to add custom domains and custom styles (css) to their Notion sites. It is a very clever service that allows creators to leverage Notion as a CMS for their website. This below tweet is the on that piqued my interest…looks like the service is growing quite quickly!

As I began to dig into the product, I became curious as to how Super implemented their custom domain functionality. About two years ago, I evaluated adding similar functionality into a SaaS app that I was building. Rather than rolling our own automated custom domain system (which appeared to require a high level-of-effort at the time), we would manually request certificates via Amazon Certificate Manager when we onboarded new customers. This approach worked for our small SaaS, but would not have been scalable had our product grown.

After stumbling upon Super, I decided to do a big of Googling. Perhaps there was a service or framework that I had missed that would make building custom domain functionality simple? After a reading a number of blogs and stack overflow forums, I stumbled across Caddy.

Why Custom Domains?

Before diving into Caddy, let’s talk about custom domains. Why do SaaS apps need support for custom domains in the first place?

Let’s use Zendesk as an example. When you sign up for Zendesk, you are asked to supply a subdomain for your new account (e.g. ravennakev.zendesk.com). Once your signup is complete, you and your employees (fake employees in RavennaKev’s case) will be able to login to Zendesk via the dedicated subdomain URL.

Additionally, Zendesk offers community portal functionality, which your customers will be able to access via ravennakev.zendesk.com/community.

While these urls are fine, I’d prefer not to have zendesk in my support URLs for RavennaKev. I’d also like to give my employees a domain that is easy to remember for when they need to log into our support system.

What I want is a custom or “vanity” domain…something like support.ravennakev.com. This url will be easier for my employees to remember and better for my customers (who likely don’t know or care about Zendesk). This is a very common scenario with SaaS apps and Zendesk does indeed offer custom domain functionality.

Custom domains are also one of the primary up-sell levers that companies use to get customers to move from a free to paid plan. Take Medium (where I host this blog) for example. In the screenshot below, which is from Medium’s docs, you can see that “an active Medium membership” is required to leverage their custom domain functionality.

If you If you are building a SaaS, you will likely want to support custom domains at some point.

How Custom Domains Work

Supporting custom domains seems trivial on the surface. Why not just ask the customer to add a CNAME record to their DNS which points their desired URL at the URL provided by the SaaS app? In the example above, why not just add a CNAME record for support.ravennakev.com to point to ravennakev.zendesk.com?

There are two challenges with this approach. The first, and most important is HTTPS. If I CNAME support.ravennakev.com to ravennakev.zendesk.com, Zendesk’s servers won’t have a TLS certificate for ravennakev.com meaning requests to support.ravennake.com will not support HTTPS.

In order to support the CNAME approach, Zendesk will not only need to build functionality to acquire a TLS cert for your domain, they will also need to build functionality to keep that certificate up to date when it expires.

Cloud companies like AWS provide this functionality out of the box with services like AWS Certificate Manager. But limitations with Load Balancers (where you typically terminate TLS in AWS environments) present challenges. Network and Application Load Balancers only supports up to 25 certificates for example, which is far too little for an ambitions SaaS founder.

The second challenge is domain verification. Zendesk will want to verify that I am indeed the owner of ravennakev.com before acquiring a TLS cert on my behalf.

Caddy

While domain verification will have to be the topic of another blog post, TLS certificate acquisition and management can fortunately be handled by Caddy.

Caddy is a web server that can automatically manage TLS certs on your behalf. It does this via direct integrations with multiple free certificate authorities including Let’s Encrypt and ZeroSSL.

Caddy can learn about the certificates it needs to support in two ways. First, it supports Dynamic SSL, meaning if it receives traffic for a host domain that it doesn’t currently have a cert for, it will automatically go and acquire one. Second, you can specify the domains that require HTTPS via the Caddy configuration file.

Using Caddy

To get started with Caddy, we first need to install it on our machine.

brew install caddy

Caddy servers are configured via a configuration filed called a Caddyfile. To try out a simple example, create a new file call Caddyfile in an empty directory and add the following:

:8080respond “Hello world”

This file is telling Caddy to listen for requests on port 8080 and respond with the string “Hello World”. Test it out by running the following:

caddy run — config ./Caddyfile

Open your browser to localhost:8080 and should see the text “Hello World”.

TLS Certs

One of Caddy’s killer features is that it can automatically acquire and manage TLS certs for any domain. Say we want to acquire a cert for the domain of this blog (ravennakev.com) for instance, we could add the following to our Caddyfile.

https://ravennakev.com {
response ‘Hello World’
}

When we start the Caddy server, if Caddy doesn’t already have a TLS cert for the ravennakev.com domain, it will go out an acquire one and persist it to disk for future use. Caddy will even handle refreshing the cert for you when it expires. Amazing.

Reverse Proxy

So now that we know about Caddy, let’s talk about how we can leverage it to add custom domain support to a SaaS app. In order get Caddy to support custom domains, we have to configure it to serve as a Reverse Proxy.

According to Nginx, a Reverse Proxy is:

a type of proxy server that typically sits behind the firewall in a private network and directs client requests to the appropriate backend server.

In layman terms, a reverse proxy receives a request, and forwards it on to a different backend server.

In our example, Caddy will terminate TLS for the custom domain, and then forward an additional HTTPS request to the upstream (non-custom) domain and then return a response. This will ensure HTTPS security throughout the request chain.

support.ravennakev.io ➡️ caddy ➡️ ravennakev.zendesk.com

Caddy Configuration

To test out our Revers Proxy functionality locally, we can use the following configuration.

https://localhost:8080 {
reverse_proxy {
to https://caddyserver.com/
}
}

The above configuration tells Caddy to listen on port 8080 and reverse proxy requests to https://caddyserver.com. We can fire up the server with the following.

caddy run — config ./Caddyfile

If you open you browser to https//:localhost:8080 you will be greeted with an empty browser window. Not ideal. When I first ran into this issue, I figured there were likely additional headers I would need to include in the proxied request in order to achieve my desired results (i.e. load https://caddyserver.com/ in the browser)

After a bit of Googling for “headers to use in reverse proxy”, I came across this great article from NGINX which describes the X-Forwarded headers that are typically used in a reverse proxy. Exactly what we need. Those headers are

X-Forwarded-ForX-Real-IPX-Forwarded-HostX-Forwarded-Proto

Digging through Caddy reverse proxy docs, I found that Caddy is already setting/augmenting the X-Forwarded-For and X-Forwarded-Proto headers…so we don’t need to worry about those. It is not modifying the Host header however, so we will need to take care of that. We will also supply a value for X-Real-IP.

Update your Caddy file with the following configuration.

https://localhost:8080 {
log {
format json
}
reverse_proxy {
to https://caddyserver.com/
header_up Host {http.reverse_proxy.upstream.host}
header_up X-Real-IP {http.reverse-proxy.upstream.address}
}
}

Restart Caddy, open the browser to https://localhost:8080 and https://caddyserver.com/ should appear with full HTTPS. Amazing.

Breaking it down

Let’s breakdown what is happening here.

https://localhost:8080 — our top level declaration tells Caddy that the following configuration block applies to all requests for https://localhost:8080.

log — the log statement tells Caddy the format in which we would like our Caddy logs to appear…in this case JSON. This allows us to see logs in the terminal when we are running Caddy. Helpful for debugging purposes.

reverse-proxy — tells Caddy we want all requests to https://localhost:8080to be reverse proxied using the following configuration block.

to — the destination domain for the reverse proxy. In network terminology, this is our upstream server.

header_up Host — tells Caddy to use the destination host (upstream, in this case caddyserver.com) as the Host header in the proxied request.

header_up X-Real-IP — tells Caddy to use the destination address (upstream, in this case 198.105.254.23) as the Real IP in the proxied request.

Wrapping Up

With the above configuration, I’ve demonstrated how you can leverage Caddy to add support for custom domain HTTPS in your SaaS app. Caddy is an amazing web server, and I have had a ton of fun getting up to speed on all of the functionality it supports.

In order to leverage this approach in a real world SaaS scenario, you will need to add a reverse proxy record for each custom domain you want to support. You will also likely want a way to automate this scenario via an API, so that when a new customer signs up for your app and needs custom domain functionality, you can automatically add their custom domain to your Caddy configuration.

Fortunately, Caddy ships with an API that will allow you to do exactly that. Automating custom domain onboarding will likely be the topic of another blog post!

--

--

Ravenna Kev

Software Developer. Currently work in Business Development at AWS. Previously founded Mesh Studio, a cloud consultancy in Seattle.