How to use Let's Encrypt with Node.js and Express

Let's Encrypt can not autoconfigure a Node.js app. Learn how to do it yourself

Author's image
Tamás Sallai
6 mins

Background

Unlike Apache and Nginx, Let's Encrypt has no way of autoconfiguring your Node.js app, as it can work in arbitrary ways, while the former two usually follow a predefined (and machine readable) configuration.

How to configure a Node.js Express server to handle Let's Encrypt HTTP authorization then?

As usual, there are several use cases, depending on your current configuration.

Let's Encrypt with Node.js

To start an HTTPS server, you'll need a certificate and the private key. Read them from the filesystem, and fire up the server. For example:

const https = require('https');
const express = require('express');
const fs = require("mz/fs");

...

const {key, cert} = await (async () => {
	const certdir = (await fs.readdir("/etc/letsencrypt/live"))[0];

	return {
		key: await fs.readFile(`/etc/letsencrypt/live/${certdir}/privkey.pem`),
		cert: await fs.readFile(`/etc/letsencrypt/live/${certdir}/fullchain.pem`)
	}
})();

const app = express();

...configure app

const httpsServer = https.createServer({key, cert}, app).listen(443)

This example uses the mz/fs library, which provides Promised filesystem methods.

Depending on your requirements, you might want a graceful shutdown handler. As the above server reads the certificate on startup, it won't use a new one unless restarted. To make sure the connections are not terminated abruptly, you can use a library:

const GracefulShutdownManager = require('@moebius/http-graceful-shutdown').GracefulShutdownManager;

...

const httpsShutdownManager = new GracefulShutdownManager(httpsServer);

process.on('SIGTERM', () => {
	httpsShutdownManager.terminate(() => {
		console.log('Server is gracefully terminated');
	});
});

Note that it will not automagically handle websockets and long-running connections. If you use any of them, you need to implement some sort of auto-retrying in the client applications.

Scenario #1: HTTP is not used at all

Example

If your app does not use HTTP (port 80), which might be the case for API-only endpoints, it is straightforward to configure Let's Encrypt. If you use the above example with the certificates and the graceful shutdown, you are already set up Node-wise.

For certbot, use standalone authorization to get the initial certificate, then start the app:

certbot certonly --standalone --keep-until-expiring --agree-tos -d $CERT_URL ...other_params && ...start_app

To renew, use the --deploy-hook to restart the app:

certbot renew --deploy-hook='...restart_app'

Scenario #2: Shut down the app during renewal

Example

If your app uses both HTTP and HTTPS and you don't want to modify it in any way to support Let's Encrypt, then just shut it down during certificate renewal.

The downside is a few seconds of downtime roughly every 60 days. If the clients use some kind of auto-retrying, they might not even notice.

To get the initial certificate, use the same script as in the previous case:

certbot certonly --standalone --keep-until-expiring --agree-tos -d $CERT_URL ...other_params && ...start_app

The only difference is that during renewal stop the app in the --pre-hook and start it again with the --post-hook:

certbot renew --pre-hook='...stop_app' --post-hook='...start_app'

Scenario #3: HTTP redirect with a public folder

Example

For most of the apps, this is the most likely scenario. When you serve all your content on HTTPS, but still use HTTP just to redirect the visitors. This is the preferred way for public-facing websites, as browsers still request the HTTP site first. If there were no redirect, users would be welcomed with an error page.

Also, in most cases, there is a public folder that is served as-is. This is usually where the assets, like the CSS, JS, HTML, and image files are.

This setup allows Let's Encrypt to use that public library for the HTTP auth. During that, it places a file into the directory, and a remote auth server tries to fetch it via HTTP, gets redirected to HTTPS, and the auth will be successful.

Node-wise, you need an HTTP server that does the redirect:

const http = require('http');

// https://stackoverflow.com/a/7458587/2032154
const httpApp = express();
httpApp.get('*', function(req, res) {  
	res.redirect('https://' + req.headers.host + req.url);
})
const httpServer = http.createServer(httpApp).listen(80);

If you use graceful shutdown, don't forget to use that for the HTTP server too:

const httpShutdownManager = new GracefulShutdownManager(httpServer);

process.on('SIGTERM', () => {
	httpsShutdownManager.terminate(() => {
		httpShutdownManager.terminate(() => {
			console.log('Server is gracefully terminated');
		});
	});
});

Since Let's Encrypt requires your app to be running even for the first auth, you need to handle the case when no certificate is present.

A simple solution uses the pem library to generate a self-signed certificate if none is found:

const pem = require('pem');

...

const {key, cert} = await (async () => {
	try {
		const certdir = (await fs.readdir("/etc/letsencrypt/live"))[0];

		return {
			key: await fs.readFile(`/etc/letsencrypt/live/${certdir}/privkey.pem`),
			cert: await fs.readFile(`/etc/letsencrypt/live/${certdir}/fullchain.pem`)
		}
	}catch(e) {
		return new Promise((res, rej) => {
			pem.createCertificate({days: 1, selfSigned: true}, function (err, keys) {
				if (err) {
					rej();
				}else {
					res({key: keys.serviceKey, cert: keys.certificate});
				}
			});
		});
	}
})();

The certbot configuration uses webroot auth, and you need to specify the path of the public folder:

certbot certonly --webroot --keep-until-expiring --agree-tos -w ...public_folder -d $CERT_URL --deploy-hook '...restart_app' ...other_params

The --deploy-hook restarts the app when a new certificate is available.

The renew works the same, so you don't even need to specify any parameters:

certbot renew

Scenario #4: HTTP redirect with no public folder

Example

If you use HTTP and you don't have a dedicated static files folder, then you need to configure a path for the Let's Encrypt auth challenge. This basically means some kind of public folder, but with a limited scope. This enables Let's Encrypt to authorize, but don't expose anything else.

To serve a folder under the /.well-known/acme-challenge path, use:

httpApp.use("/.well-known/acme-challenge", express.static("letsencrypt/.well-known/acme-challenge"));

The HTTP server config that does the redirect as well as the challenge is like:

const httpApp = express();

httpApp.use("/.well-known/acme-challenge", express.static("letsencrypt/.well-known/acme-challenge"));

httpApp.get('*', function(req, res) {  
	res.redirect('https://' + req.headers.host + req.url);
})
const httpServer = http.createServer(httpApp).listen(80);

Since you still use the webroot auth, you need to handle the no-cert case, the same as above.

The certbot configs are also the same as above:

certbot certonly --webroot --keep-until-expiring --agree-tos -w ...app_folder/letsencrypt -d $CERT_URL --deploy-hook '...restart_app' ...other_params

and

certbot renew

Scenario #5: You do not want server restarts

All the above cases are based on automatic renewals that also restart the server. In some cases this might not be acceptable, as they happen albeit rarely, still somewhat randomly. If the restarts take a long time, that could cause problems even though they only run roughly every other month.

One solution is to schedule the renewals to a part of the day when a restart is acceptable.

Another solution is to forget renewals altogether and periodically restart the server in a maintenance window. The restart then updates the certificates before starting the app.

In this case, you don't need to worry about the authorization process and the graceful shutdown. In fact, you don't need any changes to your server apart from using the current Let's Encrypt certificate.

Just use the standalone script from Scenario #1, and you are all set up:

certbot certonly --standalone --keep-until-expiring --agree-tos -d $CERT_URL ...other_params && ...start_app

The --keep-until-expiring flag instructs certbot to keep the certificate if it is not near expiry. Usually, this is the right choice, as it prevents multiple restarts to use up your quota.

But if you want to make sure that after every restart you have a new certificate, use --force-renewal instead. I don't recommend it though, as you might easily find yourself rate-limited.

July 24, 2018