Automatic SSL Solution for SaaS/MicroSaaS Applications with Caddy, Node.js and Docker
Tags: webdev, saas, ssl, backend
Published: 2024-02-29 01:30:02

Hey there! Over the past couple of years, I've had my hands on quite a few SaaS applications. One thing a lot of them have in common is this nifty feature that lets users plug in their own custom domains. Now, getting a domain up and running is pretty straightforward—you just need to give your users the server IP address or a CNAME value. But here's the catch: offering free and automatic SSL? That can get a bit tricky.

At first, I gave bash scripting a go to generate SSL with Let's Encrypt and used good old **Nginx** to create and renew SSL certificates. But let me tell you, it was a bit of a headache to maintain. I also tried out a **Cloudflare** feature, [SSL for SaaS](https://www.cloudflare.com/application-services/products/ssl-for-saas-providers/), but it asked users to add a bunch of DNS records, which wasn't really what I was looking for. What I wanted was a solution where users only had to add a CNAME.

So I dug a little deeper and came across this gem: [Caddy](https://caddyserver.com/). Caddy is this fantastic, extensible, cross-platform, open-source web server that's written in Go. The best part? It comes with automatic HTTPS. It basically condenses all the work our scripts and manual maintenance were doing into just 4-5 lines of config. So, stick around and I'll walk you through how to set up an automatic SSL solution with Caddy, Docker and a Node.js server.

## Let's Get Our Application Up and Running!

First things first, we're going to set up a friendly little express application that greets us with a cheery "Hello World" page. We'll need a couple of APIs for this.

- `/` - This one's our main star! It serves the "Hello World" page.
- `/tls-check` - Our buddy Caddy uses this to make sure it's dealing with a valid domain. We'll chat more about this later on in our journey.

Create a new directory named `caddy-saas-node-application` and then change your current directory to this new one. While we'll be using `pnpm`, you can also use `yarn` or `npm` to install packages.

To install express, run the command `pnpm add express`.

Next, update the `package.json` file to include a start script:

```json
{
  "name": "caddy-saas-node-application",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "node index.js"
  },
  "keywords": [],
  "author": "pieeee",
  "license": "ISC",
  "dependencies": {
    "express": "^4.18.2"
  }
}

```

Create a `main.js` file, which will contain the code for our Express application. Add the following code:

```json
/**
 * Express application for the Caddy SaaS Node Application.
 * @module index
 */

const express = require("express");

const app = express();

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

/**
 * Route handler for the root URL.
 * @name GET /
 * @function
 * @param {Object} req - Express request object.
 * @param {Object} res - Express response object.
 */
app.get("/", (_, res) => {
  res.send("Hello World");
});

/**
 * Array of whitelisted domains.
 * @type {string[]}
 */
const whitelistedDomains = [];

/**
 * Handles TLS (Transport Layer Security) check for a given domain.
 * This endpoint checks if the provided domain is whitelisted for TLS connections.
 * It is accessed via a GET request and expects a domain name as a query parameter.
 *
 * @route GET /tls-check
 * @param {Object} req - The request object from Express.js.
 * @param {Object} req.query - The query string object.
 * @param {string} req.query.domain - The domain name to check for TLS whitelisting.
 * @param {Object} res - The response object from Express.js.
 * @returns {Object} The response object with a status code and a JSON body.
 *          If the domain query parameter is missing, it returns a 400 status code with an error message.
 *          If the domain is found in the whitelist, it returns a 200 status code with a success message.
 *          If the domain is not in the whitelist, it returns a 403 status code with an error message.
 */
app.get("/tls-check", (req, res) => {
  const domain = req.query.domain;

  if (!domain) {
    return res.status(400).json({
      error: "Domain is required",
    });
  }

  if (whitelistedDomains.includes(domain)) {
    return res.status(200).json({
      message: "Domain is whitelisted",
    });
  }

  return res.status(403).json({
    error: "Domain is not whitelisted",
  });
});

/**
 * Start the server and listen on port 8080.
 * @name listen
 * @function
 * @param {number} port - The port number to listen on.
 * @param {Function} callback - The callback function to execute when the server starts listening.
 */
app.listen(8080, () => {
  console.log("Server is running on port :8080\nhttp://localhost:8080/");
});

```

This API endpoint `/tls-check` validates the TLS whitelisting status of a domain. It accepts a GET request with a `domain` query parameter. If the `domain` parameter is missing, it returns HTTP 400 with an error message. If the domain is present in the `whitelistedDomains` array, it responds with HTTP 200, indicating the domain is whitelisted. Otherwise, it returns HTTP 403, stating the domain is not whitelisted.

The `whitelistedDomains` contains the domain names of allowed users. In a production environment, you might validate these from your database. Now that our app is ready, lets move on to the next part.

### Docker and docker-compose setup

Create a `Dockerfile` at the root of your project directory and add the following configurations to keep your Express application container running.

```docker
# Use a node base image
FROM node:18

# Create app directory in container
WORKDIR /usr/src/app

# Install pnpm
RUN npm install -g pnpm

# Copy package.json and pnpm-lock.yaml files
COPY package.json pnpm-lock.yaml* ./

# Install app dependencies using pnpm
RUN pnpm install --frozen-lockfile

# Bundle app source inside Docker image
COPY . .

RUN pnpm run build

EXPOSE 8080

CMD ["pnpm", "start"]

```

Now, create a `docker-compose.yml` file and add the following configuration. We'll use docker-compose to manage both the app container and the caddy container in one location.

```yaml
version: "3"
services:
    app:
        build: .
        networks:
            - webnet
    caddy:
        image: caddy:2
        ports:
            - "80:80"
            - "443:443"
        volumes:
            - ./Caddyfile:/etc/caddy/Caddyfile
            - caddy_data:/data
            - caddy_config:/config
        networks:
            - webnet
        depends_on:
            - app

networks:
    webnet:

volumes:
    caddy_data:
    caddy_config:

```

The Docker Compose configuration defines two services, `app` and `caddy`, within a custom network named `webnet`. The `app` service is built from a Dockerfile in the current directory, indicating the express application. The `caddy` service uses the official Caddy server image version 2, exposing ports 80 and 443 for HTTP and HTTPS traffic, respectively. It mounts a `Caddyfile` for configuration, alongside named volumes `caddy_data` and `caddy_config` for persisting data and Caddy configuration. The `caddy` service is configured to depend on the `app` service, ensuring `app` is started first. Both services are connected via the `webnet` network, facilitating inter-service communication.

Now let’s create `Caddyfile` at the root of the project directory and add the following configs:

```yaml
{
	on_demand_tls {
		ask http://app:8080/tls-check
		burst 5
		interval 2m
	}
}

https:// {
	tls {
		on_demand
	}

	reverse_proxy app:8080
}
```

This little configuration does all the work. When a request from a new domain arrives, the `on_demand_tls` block triggers our `/tls-check` API to validate the domain. If the domain is validated, Caddy will generate an SSL certificate and assign it. The generation only occurs on the first request. By default, the generated SSLs expire in three months. After that, a new SSL is generated and assigned on the first request. The "burst 5" and "interval 2m" specify that a maximum of five certificates can be generated every two minutes. You can read more here: [Caddy on-demand automation](https://caddyserver.com/docs/json/apps/tls/automation/on_demand/)

The `https://` block enables the reverse proxy to our application. You can learn more here: [Caddy reverse proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)

That's it! All you need to do now is deploy your application on a virtual machine, install Docker, build the app, and enjoy the magic of Caddy.

### Useful Resources

- [Caddy on-demand automation](https://caddyserver.com/docs/json/apps/tls/automation/on_demand/)
- [Caddy reverse proxy](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)
- [Install Docker](https://docs.docker.com/engine/install/)