DEV Community

Cover image for Automatic SSL Solution for SaaS/MicroSaaS Applications with Caddy, Node.js and Docker
Sheikh Ahnaf Hasan
Sheikh Ahnaf Hasan

Posted on

Automatic SSL Solution for SaaS/MicroSaaS Applications with Caddy, Node.js and Docker

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, 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. 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:

{
  "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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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

/**
 * 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/");
});

Enter fullscreen mode Exit fullscreen mode

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.

# 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"]

Enter fullscreen mode Exit fullscreen mode

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.

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:

Enter fullscreen mode Exit fullscreen mode

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:

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

https:// {
    tls {
        on_demand
    }

    reverse_proxy app:8080
}
Enter fullscreen mode Exit fullscreen mode

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

The https:// block enables the reverse proxy to our application. You can learn more here: Caddy 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

Top comments (0)