HTTPS for a Node.js app: ACME certificate with acme.sh and auto-renewal
PrintA Node.js application on a VPS gets HTTPS in one of two ways: put Nginx in front of it and let Certbot handle the certificate (the usual choice for production), or let the Node.js process serve HTTPS itself with a certificate obtained by acme.sh. Both work with the Sectigo ACME subscription and renew automatically. This guide explains when to choose each and walks through the second in full.
On TopHost Web Hosting SSD plans, Node.js applications are created in cPanel with Setup Node.js App and run behind the hosting web server, so they use the domain's certificate from AutoSSL or one installed as in acme.sh on cPanel hosting. Nothing from this page is needed there.
Which approach to choose
| Nginx in front of Node.js | Node.js serves HTTPS itself | |
|---|---|---|
| Who holds ports 80 and 443 | Nginx; the app listens on a local port such as 3000 | The Node.js process |
| Certificate | Certbot with --nginx | acme.sh, files read by the app |
| After renewal | Nginx reloads, the app is not restarted | The app is restarted through PM2 |
| Good for | Production sites, several apps on one server | A single small app, no other web server |
Approach 1: Nginx as a reverse proxy
Make the app listen only locally, for example with app.listen(3000, '127.0.0.1'), then create an Nginx server block for the domain:
server {
listen 80;
server_name example.com www.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Reload Nginx, then obtain the certificate with Certbot in automatic mode, exactly as in Certbot with Sectigo ACME on Apache and Nginx. Certbot adds the HTTPS part to this server block and renews the certificate on its own.
Approach 2: Node.js with its own HTTPS server
The example assumes an Express app in /home/appuser/myapp, run by the non-root user appuser under PM2. acme.sh is installed under the same user, so its renewal job restarts the right PM2 process.
Step 1. Allow Node.js to use ports 80 and 443
Ports below 1024 are reserved for root. Instead of running the app as root, give the Node.js binary this one permission:
sudo setcap 'cap_net_bind_service=+ep' "$(readlink -f "$(which node)")"
Repeat the command after every Node.js upgrade, because the new binary does not keep the permission. Open ports 80 and 443 in the server firewall.
Step 2. Serve the validation folder over HTTP
For the first certificate, the app must answer on port 80 and serve files from public/.well-known/acme-challenge/. Express hides folders whose names begin with a dot, so mount that folder explicitly:
const express = require('express');
const http = require('http');
const path = require('path');
const webroot = path.join(__dirname, 'public');
const app = express();
app.use('/.well-known/acme-challenge',
express.static(path.join(webroot, '.well-known', 'acme-challenge')));
app.use(express.static(webroot));
http.createServer(app).listen(80);
Step 3. Run the app with PM2
sudo npm install -g pm2
pm2 start server.js --name myapp
pm2 startup systemd
pm2 save
pm2 startup prints a command that starts with sudo; copy and run it, so the app starts again after a reboot. pm2 save remembers the current list of processes.
Step 4. Install acme.sh and register
As appuser:
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc
acme.sh --register-account --server https://acme.sectigo.com/v2/DV \
--eab-kid YOUR_EAB_MAC_ID --eab-hmac-key YOUR_EAB_MAC_KEY
The values come from Sectigo ACME Certificate-as-a-Service in the client area.
Step 5. Issue the certificate and give it to the app
acme.sh --issue --server https://acme.sectigo.com/v2/DV \
-d example.com -d www.example.com \
-w /home/appuser/myapp/public
mkdir -p /home/appuser/myapp/cert
chmod 700 /home/appuser/myapp/cert
acme.sh --install-cert -d example.com \
--key-file /home/appuser/myapp/cert/privkey.pem \
--fullchain-file /home/appuser/myapp/cert/fullchain.pem \
--reloadcmd "pm2 restart myapp"
The folder after -w must be the same one the app serves in step 2. acme.sh writes the private key with restricted permissions; keep the cert folder out of public and out of your Git repository.
Step 6. Switch the app to HTTPS
Replace server.js with a version that keeps validation on HTTP, sends everything else to HTTPS and loads the certificate:
const express = require('express');
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const webroot = path.join(__dirname, 'public');
const certDir = path.join(__dirname, 'cert');
// the application, served over HTTPS
const app = express();
app.use(express.static(webroot));
app.get('/', function (req, res) {
res.send('Hello over HTTPS');
});
// port 80: ACME validation stays on HTTP, the rest is redirected
const plain = express();
plain.use('/.well-known/acme-challenge',
express.static(path.join(webroot, '.well-known', 'acme-challenge')));
plain.use(function (req, res) {
res.redirect(301, 'https://' + req.hostname + req.originalUrl);
});
http.createServer(plain).listen(80);
https.createServer({
key: fs.readFileSync(path.join(certDir, 'privkey.pem')),
cert: fs.readFileSync(path.join(certDir, 'fullchain.pem'))
}, app).listen(443);
Apply it with pm2 restart myapp. Node.js reads the certificate only at start-up; that is why the --reloadcmd from step 5 restarts the app after every renewal.
Step 7. Check HTTPS and renewal
- Open
https://example.comand check the certificate details behind the padlock. Openhttp://example.comas well: it should redirect to HTTPS. - As
appuser, runcrontab -land look for the line withacme.sh --cron. - Run
acme.sh --renew -d example.com --forceonce to test renewal and the automatic restart, then checkpm2 status.
Troubleshooting
- Error: listen EACCES on port 80 or 443. The capability from step 1 is missing, often after a Node.js upgrade or when Node.js is installed through a version manager in another path.
- Error: listen EADDRINUSE. Another program, often Nginx or Apache, already uses the port. Stop it or use approach 1.
- Validation returns 404. The
-wfolder is not the one served at/.well-known/acme-challenge/, or a redirect to HTTPS catches the validation request before the certificate exists. - The app did not restart after renewal. acme.sh and PM2 run under different users. Install both under the same user, or put the right command in
--reloadcmd. - Domain not found when running
--renewor--install-cert. Add--ecc, because current acme.sh versions issue ECC certificates by default.
If you get stuck, open a ticket with the command, its output and the PM2 log (pm2 logs myapp), without the EAB MAC Key.