About
The concrete implementation was made on MacOS, but it should be quite easy to alter and adopt this example to both Windows and Linux.
Exclusively tested with Vite 6+!
In this article we will implement the ideas presented here - Production Parity: Routing Local Traffic with a Reverse Proxy
The main idea is to achieve parity between our development setup and production.
You might think: 'Why would this be necessary?' - The argumentation for achieving environment parity is covered here: the-red-pill-of-software-delivery-unmasking-magic-code-and-building-for-reality
This article is a continuation of creating-the-pinnacle-of-niche-software-using-vite-plugin-elm-watch and the example created there will be extended in the branch named feature/use-custom-domain - Link!
Goals
- Use a custom domain name and completely ditch the usage of
localhost:portwhen working on our solution from the browser - Develop from a devcontainer
- Enable HTTPS
Now let's go through how we achieve this fine objective :)
The moving parts
Next up we will setup the following:
- Add nginx to the solution
- Configure our
hostsfile - Configure nginx
- Create and trust certificates
Reverse Proxy
First of all we need to add a reverse proxy like nginx to handle routing from a url/domain to the vite server. This won't break HRL and will bring all the benefits discussed earlier. I'll use nginx throughout this article.
If you are following along from the previous article, it should be quite straight forward to extend the the code in the following. A working example can be found here: Link! in the branch use-custom-domanin
Add nginx
First we'll add nginx to the docker-compose.yml
name: elm-first
services:
dev: ..
nginx:
image: nginx:alpine
container_name: elm-first-nginx
ports:
- "80:80"
- "443:443"
networks:
- internal
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - ./ssl:/etc/nginx/certs:ro
depends_on:
- dev
volumes:
elm-first-vite-example:
networks:
internal:
driver: bridge
Add endpoint to your hosts file
On mac it's simply done by:
> sudo vi /etc/hosts and add
127.0.0.1 happy.now www.happy.now
127.0.0.1 happy.dev
Save the file an restart the devcontainer.
I had some troubles with when the mappings in the hosts file were picked up by the browsers.
But now we can access the default nginx site by simply using: happy.now
Observe that using happy.dev will redirect to https://happy.dev
Next we'll route to our vite server running on port 3456
Serve the running vite sever
- Add a custom nginx.conf
touch .d evcontainer/nginx.conf
events {
worker_connections 1024;
}
http {
# Define resolver to handle host.docker.internal
resolver 127.0.0.11;
server {
listen 80;
server_name www.happy.dev happy.dev www.happy.now happy.now;
# Proxy frontend requests to Elm dev server
location / {
set $frontend_upstream host.docker.internal:3456;
proxy_pass http://$frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
sub_filter '</head>' '<meta name="environment-name" content="local" /></head>';
sub_filter_once on;
# Only apply sub_filter to index.html
# sub_filter_types text/html;
}
}
- Uncomment in the
docker-compose.yml
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- From the host machine! run:
docker exec elm-first-nginx nginx -t - It the config is ok now run:
docker exec elm-first-nginx nginx -s reload - Start the vite server by running:
npm run dev - The vite server will bounce access from anything but localhost so update the
allowedHostswhith:
allowedHosts: ["host.docker.internal", "www.happy.now", "happy.now", "www.happy.dev", "happy.dev"],
- Reload the page and be a happy developer now!
Finally we will enable HTTPS.
Enable HTTPS
First we'll need to rearrage the project a bit. Start with stopping the devcontainer.
Rearrage nginx config
Add the missing nginx folder and add files.
project-root/
├── .devcontainer/
│ ├── ssl/
│ ├── devcontainer.json
│ ├── docker-compose.yml
│ ├── Dockerfile.devmachine
│ └── nginx/
| ├── nginx.conf
| └── conf.d/
| └── proxy_elm_env.inc <-- NEW
The new configs should look like this:
nginx.conf
events {
worker_connections 1024;
}
http {
# Define resolver to handle host.docker.internal
resolver 127.0.0.11;
server {
listen 80;
server_name www.happy.dev happy.dev www.happy.now happy.now;
# Proxy frontend requests to Elm dev server
location / {
include /etc/nginx/conf.d/proxy_elm_env.inc;
}
}
server {
listen 443 ssl;
server_name www.happy.dev happy.dev;
ssl_certificate /etc/nginx/certs/happy.dev.crt;
ssl_certificate_key /etc/nginx/certs/happy.dev.key;
location / {
include /etc/nginx/conf.d/proxy_elm_env.inc;
}
}
}
proxy_elm_env.inc
proxy_pass http://$frontend_upstream;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
sub_filter '</head>' '<meta name="environment-name" content="local" /></head>';
Certificates
We need a certificate in order to use HTTPS. Here we'll create a self signed cert.
Let's use happy as our domain name.
Since we will be using squidex in the next article we create a cert which can handle both happy.dev and squidex.happy.dev.
Notes on TLD's
It's important to notice that some TLD's are special.
dev, app, and page will all have HTTPS enforced by the browser if used. Unlike test, localhost or local which, at least on MacOS, will be treated as safe
Method 1 (Not so good)
Using this method will require a cert for each domain name.
Eg. one for both happy.dev and squidex.happy.dev
cd /workspace/.devcontainer/ssl
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout happy.dev.key -out happy.dev.crt \
-subj "/CN=happy.dev" \
-addext "subjectAltName=DNS:happy.dev,DNS:*.happy.dev"
# Set proper permissions
chmod 644 squidex.happy.dev.crt
chmod 600 squidex.happy.dev.key
Repeat for the happy.dev domain. But I prefer to just use method 3.
Method 2 (Better)
Using this method will make you only need one certificate.
Create a req.cnf file in eg. the .devcontainer/ssh folder.
[req]
distinguished_name = req_distinguished_name
x509_extensions = v3_req
prompt = no
[req_distinguished_name]
C = DK
ST = Central Denmark Region
L = Tilst
O = Kompromisløs
OU = IT
CN = happy.dev
[v3_req]
keyUsage = nonRepudiation, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = happy.dev
DNS.2 = squidex.happy.dev
Generate Private key: openssl genrsa -out happy.dev.key 2048
Generate Self-Signed Certificate: openssl req -x509 -new -key happy.dev.key -sha256 -days 365 -out happy.dev.crt -config req.cnf -extensions v3_req
chmod 644 happy.dev.crt
chmod 600 happy.dev.key
Trust the Certificate in macOS Keychain
To make browsers on your Mac trust your self-signed certificate, you need to add the certificate to your macOS system keychain and then explicitly set its trust level.
Most web browsers on macOS, including Chrome, Safari, and Firefox (by default), rely on the certificates stored in the system's Keychain Access utility.
Here is the step-by-step guide to get your browser to trust the happy.dev certificate:
Step 1: Open Keychain Access
Open Spotlight Search by pressing Cmd + Space.
Type Keychain Access and press Enter.
Step 2: Import the Certificate
In the Keychain Access application, make sure you have the login or System keychain selected on the left sidebar.
Go to the menu bar and select File > Import Items....
Navigate to the location of your happy.dev.crt file, select it, and click Open.
You may be prompted to enter your administrator password to add the certificate to the keychain.
Step 3: Set the Trust Level
In Keychain Access, find your imported certificate. It should be listed under its common name, which is likely happy.dev or a similar identifier.
Double-click the certificate to open its details window.
Expand the Trust section.
You will see a dropdown menu labeled "When using this certificate:". By default, it will say "Use System Defaults."
Change this dropdown to "Always Trust."
Close the window. You will be prompted to enter your administrator password again to save the changes.
Step 4: Restart Your Browser
After you've set the trust level, it's a good practice to completely quit and restart your web browser (e.g., Safari, Chrome, or Firefox). This ensures that the browser reloads the certificate trust settings from the system keychain.
Now, when you navigate to https://happy.dev, your browser should recognize the certificate as valid, and you will no longer see the "Your connection is not private" or security warning pages.
Update your hostfile
sudo vi /etc/hosts
##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting. Do not change this entry.
##
....
127.0.0.1 happy.dev www.happy.dev <-- ADD
Use a script to add cert
All the above steps can be automated using this script:
#!/bin/bash
# Example
# bash> setup-certs.sh happy.dev 365
# Default values
CERT_NAME="${1:-localhost}"
DAYS="${2:-365}"
CERT_DIR="${3:-.}"
# Generate private key
openssl genrsa -out "$CERT_DIR/$CERT_NAME.key" 2048
# Generate self-signed certificate
openssl req -x509 -new -key "$CERT_DIR/$CERT_NAME.key" -sha256 -days $DAYS \
-out "$CERT_DIR/$CERT_NAME.crt" -config req.cnf -extensions v3_req
# Set proper permissions
chmod 644 "$CERT_DIR/$CERT_NAME.crt"
chmod 600 "$CERT_DIR/$CERT_NAME.key"
# Add certificate to macOS Keychain (macOS only)
if [[ "$OSTYPE" == "darwin"* ]]; then
echo "Adding certificate to macOS Keychain..."
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "$CERT_DIR/$CERT_NAME.crt"
# Add domains to /etc/hosts
echo "Adding domains to /etc/hosts..."
HOSTS_ENTRY="127.0.0.1 $CERT_NAME squidex.$CERT_NAME"
if ! grep -q "$CERT_NAME" /etc/hosts; then
echo "$HOSTS_ENTRY" | sudo tee -a /etc/hosts > /dev/null
echo "Added: $HOSTS_ENTRY"
else
echo "$CERT_NAME already in /etc/hosts"
fi
fi
echo "✅ Certificate created!"
echo "Certificate: $CERT_DIR/$CERT_NAME.crt"
echo "Key: $CERT_DIR/$CERT_NAME.key"
If the file doesn't have execute rights:
bash setup-certs.sh happy.dev 365
I ran it from .devcontainer/ssh/
Notes for Linux users
host.docker.internal doesn't work out of the box like it does on macOS. You need to add this to the docker-compose.yml under the nginx service:
extra_hosts:
- "host.docker.internal:host-gateway"
Conclusion
We now have a professional-grade development setup that eliminates the need for localhost entirely. By routing traffic through a reverse proxy with valid SSL handling, we have achieved production parity on our local machines.
Is this more effort than simply running a dev server on a random port? Yes. But the trade-off is significant:
Zero "Magic" Code: You no longer need if (development) { ... } blocks to handle URLs or protocol differences.
Security First: You catch CORS, HSTS, and SSL issues during development rather than at 2 AM during a production deployment.
Skill Mastery: You've moved beyond being a "local-only" developer to understanding the networking layers that power modern web applications.
Top comments (0)