Automatic SSL Solution for SaaS/MicroSaaS Applications with Caddy, Node.js and Docker
Sheikh Ahnaf Hasan
Posted on February 29, 2024
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"
}
}
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/");
});
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"]
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:
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
}
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
Posted on February 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 29, 2024