
Local Node Development with Custom Subdomains & SSL (on OSX 10.15 Catalina)
Hi, I’m Pete, and I’m sharing this because everything I needed to know was spread all over the place. Hopefully this helps you out.
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:
- Generate self-signed cert
- Add it to the OSX keychain
- Set up your apps (with bonus CORS content)
- Update /etc/hosts
- 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

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
.


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
):

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 System
window in Keychain Access
.
It will appear like so:

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



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:

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.envREACT_APP_API_HOST=https://api.localhistorymuseum.com
REACT_APP_TICKETS_HOST=https://tickets.localhistorymuseum.com/
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:
REACT_APP_BASE_URL=https://exhibits.localhistorymuseum.com/
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 = [
process.env.TICKET_CORS_DOMAIN,
process.env.EXHIBITS_CORS_DOMAIN
];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') {
https
.createServer(
{
key: fs.readFileSync(`/path/to/${process.env.KEY_NAME}`),
cert: fs.readFileSync(`/path/to/${process.env.CERT_NAME}`),
passphrase: process.env.CERT_PASSPHRASE
},
app
)
.listen(PORT, function() {
console.log('>> HTTPS << server listening on ' + PORT);
});
}
Wow this contains a lot of stuff.
- pull in
https
andcors
tools - set up CORS delegate
- assign CORS delegate to all requests
- define GET request to
/data
that returns a little sample JSON - 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:
127.0.0.1 exhibits.localhistorymuseum.com
127.0.0.1 tickets.localhistorymuseum.com
127.0.0.1 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 infoworker_processes 1;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65; # EXHIBITS
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 / {
proxy_pass https://0.0.0.0:3040;
}
} # TICKETS
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 / {
proxy_pass https://0.0.0.0:3050;
}
} # API
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 / {
proxy_pass https://0.0.0.0:3060;
}
}
}
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 oldantelope
? You’ll want to just runnginx
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: [] }
Godspeed
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 🙏
Cheers!