Local Node Development with Custom Subdomains & SSL (on OSX 10.15 Catalina)

Why? Parity with production pays off.

btw — the use case that started me down this path was local cookie testing, if you must know 🤷

The tech used here includes OSX, OpenSSL, Node.js + Express, JavaScript + React (via create-react-app), and Nginx. This may not match your specific tools, but hopefully the concepts + config are useful.

To keep this from being a full blown foobar example, I’ll use… a natural history museum as an example!

Let’s say our production site is historymuseum.com.

Here are my constraints:

  • Visiting https://exhibits.localhistorymuseum.com points to a react app on port 3040.
  • Visiting https://tickets.localhistorymuseum.com points to another react app on port 3050.
  • Requests to https://api.localhistorymuseum.com point to example express app on 3060.
  • I don’t want to see port numbers on the URLs.
  • I want to minimize security alerts in the browser.

Here’s a summary of the work to be done:

  1. Generate self-signed cert
  2. Add it to the OSX keychain
  3. Set up your apps (with bonus CORS content)
  4. Update /etc/hosts
  5. Set up Nginx

Let’s get started!

Generate your key/CA

We’re acting as our own certificate authority in this case. We’ll use that key to generate a certificate. If you want to get into the nitty-gritty, I learned a ton from this guide.

I recommend putting these in a common system location like /etc/ssl, and giving each a dir like /etc/ssl/certs/localhistorymuseum.com/.

This key will be our CA. Call it whatever you like, museumCA.key, rootCA.key, elephant.key, are some examples. Since the directory has plenty of info, I’ll just use host.key.

You’ll need to provide a passphrase here, too. Since it’s for local dev only, I’ll just use the word antelope.

openssl genrsa -des3 -out host.key 2048

The -des3 option specifies how the private key is encrypted with a password. Without a cipher option, the private key is not encrypted, and no password is required. — https://stackoverflow.com/questions/25459512/what-is-diffrent-between-rsa-and-rsa-des3

Image for post
Image for post
looking histerrific

Generate your cert

Now we’ll use that key to sign a certificate. You’ll need your handy passphrase on hand (antelope). In the localhistorymuseum.com directory run:

openssl req -x509 -new -nodes -key host.key -sha256 -days 1024 -out host.pem

If you’re supporting subdomains like I am, you’ll want to enter a fully qualified host name of *.localhistorymuseum.com.

Image for post
Image for post
Image for post
Image for post
Now we have our cert!

Add it as a trusted cert in the OSX keychain

Open up Keychain Access in OSX and you’ll see something like this (if you click on System):

Image for post
Image for post

Then the easiest way to add the cert from a command line location is to pop open your localhistorymuseum.com folder via open .. Then drag & drop your cert into the Systemwindow in Keychain Access.

It will appear like so:

Image for post
Image for post

Now that OSX is aware your cert exists, you must set the correct behavior.

Image for post
Image for post
This will pop open a dialog. You’ll have to expand the “Trust” section.
Image for post
Image for post
set this to Always Trust (this will auto-set all drop-downs). We want that.
Image for post
Image for post
We’re all set!

Set up your apps

I’m going to limit the amount of boilerplate I provide here because this stuff is really well documented elsewhere. Please comment and ask if I missed something you would really find helpful. 🙏

For the frontend apps, I’m using create-react-app. For the express app, I’m going to hand roll this to keep the magic to a minimum.

➜ create-react-app exhibits-web
➜ create-react-app tickets-web
➜ mkdir api
➜ yarn init
➜ touch index.js
➜ touch .env
➜ et cetera...

I’ve made some very light modifications to distinguish them from one another. When I run them all, this is what I see:

Image for post
Image for post

Configure our apps to use custom (sub)domains

In your .env files for the frontend apps, you may want to add references to your local subdomains for URL construction config.

# create-react-app automatically pulls in env vars prefixed 
# with REACT_APP_ to process.env

For example, if you were using axios you may want to set the baseUrl in the config to ensure you don’t have relative URL hijinks. I set the following:


Configure our apps for HTTPS

For exhibits-web and tickets-web (via create-react-app) this is easy. We just set an env var inline when we run it like this: HTTPS=true yarn start.

The express app is a little more challenging. We only require this locally, so I wrap it in a NODE_ENV check, and then we have to create a secure server with our key, certificate, and passphrase.

Am I 100% sure I need to do this since we’re proxying this anyway? Ask the SSL gods. I can tell you this code runs. worksforme.jpg 😄

const https = require('https');
const cors = require('cors');
// configure allowed list via .env
export const ALLOWED_ORIGINS = [
const defaultCorsOptions = {
optionsSuccessStatus: 200,
allowedHeaders: 'Origin, X-Requested-With, Content-Type, Accept, Authorization',
exposedHeaders: true,
credentials: true
const corsConfigDelegate = (req, callback) => {
if (ALLOWED_ORIGINS.includes(req.headers['origin'])) {
// yes CORS
return callback(null, { ...defaultCorsOptions, origin: true });
return callback(null, { origin: false }); // no CORS
...app.options('*', cors(corsConfigDelegate));
app.use('*', cors(corsConfigDelegate));
...app.get('/data', (req, res) => res.json({ exhibits: [], tickets: [] }));...if (process.env.NODE_ENV === 'development') {
key: fs.readFileSync(`/path/to/${process.env.KEY_NAME}`),
cert: fs.readFileSync(`/path/to/${process.env.CERT_NAME}`),
passphrase: process.env.CERT_PASSPHRASE
.listen(PORT, function() {
console.log('>> HTTPS << server listening on ' + PORT);

Wow this contains a lot of stuff.

  1. pull in https and cors tools
  2. set up CORS delegate
  3. assign CORS delegate to all requests
  4. define GET request to /data that returns a little sample JSON
  5. start the HTTPS server w/cert

Loop back your custom URLs to localhost

We’re going to modify /etc/hosts on OSX, which will point our host names to IP addresses. Namely, *.localhistorymusuem.com to localhost. We don’t want our traffic escaping into the real world!

I edited it with vim, but you can use whatever you like. Just be mindful that this file is owned by the system, so you’ll have to sudo or enter your password to change it.

sudo /etc/hosts

I added the following lines:       exhibits.localhistorymuseum.com tickets.localhistorymuseum.com api.localhistorymuseum.com

Just save, close, and you’re good to go!

Set up Nginx

I chose Nginx because it’s the tool I’m most familiar with. I’m sure there are parallels in HAProxy, lighttpd, or whatever flavor of web server you enjoy. I installed via homebrew.

We’re going to configure Nginx to support our 3 apps, and use theproxy_pass directive to map our URLs to apps + ports.

The config may be found at/usr/local/etc/nginx/nginx.conf. Here’s what mine looks like (mostly):

# Note: I omitted logs to keep this reasonably readable...
# see this full example on nginx.org for more info
worker_processes 1;
events {
worker_connections 1024;
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name exhibits.localhistorymuseum.com;
ssl_certificate /etc/ssl/certs/localhistorymuseum.com/host.pem;
ssl_certificate_key /etc/ssl/certs/localhistorymuseum.com/host.key;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name tickets.localhistorymuseum.com;
ssl_certificate /etc/ssl/certs/localhistorymuseum.com/host.pem;
ssl_certificate_key /etc/ssl/certs/localhistorymuseum.com/host.key;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name api.localhistorymuseum.com;
ssl_certificate /etc/ssl/certs/localhistorymuseum.com/host.pem;
ssl_certificate_key /etc/ssl/certs/localhistorymuseum.com/host.key;
ssl_ciphers HIGH:!aNULL:!MD5;
location / {

To see if your config is OK, run nginx -t.

You can usually run nginx with brew services start nginx but… not this time. Remember the passphrase we assigned to our certificate authority? Good old antelope? You’ll want to just run nginx directly from the command line and you will be prompted for the passphrase. Enter that, and sailing will be smooth.

Pulling it all together

OK, so by now we have:

  • a self-signed cert localhistorymuseum.com
  • 3 apps (2 frontend, 1 api), running on ports 3040, 3050, 3060
  • an updated /etc/hosts file
  • a configured nginx instance running

If you dropped the following code into one of your frontend apps, I think you’d find that it works!

const response = await fetch('https://api.localhistorymuseum.com/data');
const data = await response.json();
console.log(JSON.stringify(data)); // { exhibits: [], tickets: [] }


I distilled this from my existing solution, so there may be a rough edge here and there. If this blows up, I’ll put together a repo with a working example, otherwise, feel free to ask questions 🙏



Written by

Professional typist — retired dungeon master 🐉— 👨🏻❤️🍕 — podcast about books at www.take.fyi

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store