When developing multiple web applications and microservices locally, managing local port numbers quickly becomes tedious. Switching between localhost:3000, localhost:5000, localhost:8080, and localhost:9099 leads to port collisions, CORS friction, and cookie domain mismatches.
To solve this, portless - hosted on GitHub - provides local domains without port exposure; but with automatic SSL certificates (e.g., https://myproject.localhost/) mapping seamlessly to your local dev processes.
However, the main development stack targeted seems to be anything Node.js based and pairing portless with the Firebase Emulator Suite introduces unique challenges because of how Firebase orchestrates its networking and emulator processes.
The Problem
Standard frontend dev servers (like Vite, Next.js, or Webpack Dev Server) usually take a dynamically injected PORT environment variable or command-line argument.
With Firebase Local Emulators:
- Multiple Co-existing Ports: Firebase spins up several emulators simultaneously (Hosting on
5000, Functions on5001, Firestore on8080, Auth on9099, and the Emulator UI on4000). - Fixed Port Defaults: The Firebase CLI does not automatically adapt its Hosting emulator port to arbitrary environment variables like
PORT=34567provided by automatic reverse proxies. - HTTP vs HTTPS: Firebase Hosting emulator serves over plain HTTP (
http://127.0.0.1:5000), but modern browser features, secure cookies, and third-party auth callbacks frequently require HTTPS. - Multiple projects: Running multiple projects requires different unique ports assigned, and humans get confused easily. Having a project-related domain as URL is a productivity booster.
Architectural Obstacles & Exploration
Obstacle 1: The Injected PORT Fallacy
Many proxy wrappers rely on launching a child command and expecting the framework to read process.env.PORT:
# What portless typically does under the hood
PORT=49152 npm run dev
When running firebase emulators:start, Firebase ignores process.env.PORT and defaults to port 5000 (or the port explicitly specified under "emulators"."hosting"."port" in firebase.json).
Obstacle 2: The Root vs. User Space Split & routes.json Desynchronization
portless relies on a two-tier architecture:
- The Reverse Proxy Daemon: Needs to bind to privileged ports (
80for HTTP and443for HTTPS), requiring root privileges (sudo portless proxyor a system daemon). - The Developer CLI: Runs in regular user space to launch child processes and register routes on demand (
portless).
Under the hood, route registrations are stored in a state file called routes.json. Because of the separation between root and user space, two major issues arise:
1. The Disconnected routes.json Path
When the proxy daemon runs as root, its $HOME is /root/, so it listens for route changes inside:
/root/.portless/routes.json
Meanwhile, when the developer runs portless as an unprivileged user, the CLI writes new mappings to:
$HOME/.portless/routes.json
The CLI reports success:
✔ Registered route: iosltd.localhost -> 127.0.0.1:5000 in $HOME/.portless/routes.json
✔ Starting child process: firebase emulators:start --only hosting
However, navigating to https://iosltd.localhost/ in the browser fails with a 502 Bad Gateway or 404 Not Found, and the root proxy daemon emits:
[portless:proxy] [WARN] Incoming request for "https://iosltd.localhost/"
[portless:proxy] [ERROR] Hostname "iosltd.localhost" not found in active routes table (/root/.portless/routes.json)
[portless:proxy] [INFO] Loaded routes: 0 registered targets
2. File Ownership & EACCES Permission Errors
If the daemon is started pointing directly to the user's directory without dropping privileges, it creates or overwrites routes.json owned by root:root. The next time you run portless without sudo, the command immediately crashes with:
node:fs:585
handleErrorFromBinding(ctx);
^
Error: EACCES: permission denied, open '$HOME/.portless/routes.json'
at Object.openSync (node:fs:585:18)
at Object.writeFileSync (node:fs:2334:35)
at registerRoute (/usr/local/lib/node_modules/portless/lib/routes.js:42:8)
at async start (/usr/local/lib/node_modules/portless/bin/cli.js:88:5) {
errno: -13,
syscall: 'open',
code: 'EACCES',
path: '$HOME/.portless/routes.json'
}
The Resolution
Explicitly synchronize the state directory across both root and user environments using PORTLESS_STATE_DIR, and ensure the directory has user-level write permissions:
# 1. Export in user environment (~/.bashrc or ~/.zshrc):
export PORTLESS_STATE_DIR="$HOME/.portless"
# 2. When starting the elevated proxy daemon, pass the user state directory:
sudo PORTLESS_STATE_DIR="$HOME/.portless" portless proxy
# 3. Ensure proper file ownership if root previously touched the file:
sudo chown -R $USER:$USER "$HOME/.portless"
Obstacle 3: Startup Delays with Full Emulator Suite
Running firebase emulators:start without filtering launches Java-based emulators (Firestore, Pub/Sub, Storage) which takes several seconds. When portless probes the port before the Hosting emulator binds, it might report a connection timeout.
Resolution: Narrow the emulator scope for web hosting development:
firebase emulators:start --only hosting
The Solution: Explicit Configuration
The cleanest approach is to define an explicit "portless" declaration in your project's package.json mapping the domain alias directly to Firebase Hosting's port (e.g. 5000 or custom port like 5002), while delegating the startup to npm run dev. That's the default launch script in portless. But what if you don't have a package.json file? It won't hurt or damage your project (e.g. a static website) to create a lightweight one—it provides a standard place for dev scripts and local tooling.
1. package.json Configuration
Here's a sample configuration:
{
"name": "ios-website",
"version": "2.0.1",
"description": "IOS Indian Ocean Software Ltd. - Software Solutions from Mauritius",
"scripts": {
"dev": "firebase emulators:start --only hosting"
},
"portless": {
"name": "iosltd",
"appPort": 5000
}
}
- Add the standard project metadata like
name,version, anddescription. - Add the
portlessconfiguration block:portless.name: Assigns the local hostnamehttps://iosltd.localhost/. If omitted, the top-levelnamevalue is used.portless.appPort: Points the reverse proxy directly to the internal port of your Firebase emulator (5000, or whatever port is set infirebase.json).
scripts.dev: Runs the Firebase Hosting emulator exclusively. This is the commandportlessruns automatically.
2. firebase.json Configuration
Here is the corresponding firebase.json configuration, configuring static hosting and emulator ports:
{
"hosting": {
"public": "public",
"cleanUrls": true,
"trailingSlash": false,
"ignore": [
"firebase.json",
"!**/.well-known/**",
"**/.*",
"**/node_modules/**"
]
},
"emulators": {
"hosting": {
"port": 5000
},
"ui": {
"enabled": true
},
"singleProjectMode": true
}
}
Tip
Always ensure that "portless"."appPort" in package.json matches "emulators"."hosting"."port" in firebase.json.
Running the Setup
Starting the Dev Environment via Portless
Run the portless command from the root of your project:
portless
This minimal approach relies on the default launch behaviour of portless and uses the configuration in package.json as described above. portless will launch the proxy route and run npm run dev in the child process.
Terminal Output
portless
-- Proxy is running
-- getblogged.localhost (auto-resolves to 127.0.0.1)
-- Name "iosltd" (from portless.json)
-- Using port 5000 (fixed)
-> https://iosltd.localhost
Running: PORT=5000 HOST=127.0.0.1 PORTLESS_URL=https://iosltd.localhost NODE_EXTRA_CA_CERTS="$HOME/.portless/ca.pem" npm run dev
> iosltd@2.0.1 dev
> firebase emulators:start --only hosting
i emulators: Starting emulators: hosting
⚠ hub: emulator hub unable to start on port 4400, starting on 4401 instead.
⚠ logging: Logging Emulator unable to start on port 4500, starting on 4501 instead.
i hosting[iosltd]: Serving hosting files from: posts/_site
✔ hosting[iosltd]: Local server: http://127.0.0.1:5000
⚠ emulators: The Emulator UI is not starting because none of the running emulators have a UI component.
┌─────────────────────────────────────────────────────────────┐
│ ✔ All emulators ready! It is now safe to connect your app. │
└─────────────────────────────────────────────────────────────┘
┌──────────┬────────────────┐
│ Emulator │ Host:Port │
├──────────┼────────────────┤
│ Hosting │ 127.0.0.1:5000 │
└──────────┴────────────────┘
✔ Proxy active at: https://iosltd.localhost/
Verification & Testing
You can now curl or open your browser directly to the clean .localhost domain with TLS:
curl -k https://iosltd.localhost/
Output
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IOS Indian Ocean Software Ltd.</title>
...
Stopping and Restarting the Proxy (Applying Changes)
When you modify routes.json, change environment variables (PORTLESS_STATE_DIR), update certificates, or alter your project's port mapping, you need to cleanly stop the running portless reverse proxy daemon so the changes take effect.
1. Stopping the Proxy Daemon
If you started the proxy via sudo portless proxy or as a background service:
# Graceful stop via CLI
sudo portless stop
# Or terminate the proxy process directly
sudo pkill -f "portless proxy"
2. Ensuring Ports 80 & 443 are Released
If the proxy crashed or did not release ports cleanly, verify that no orphaned processes are occupying the privileged web ports:
sudo lsof -i :80 -i :443
To forcefully release the ports if needed:
sudo fuser -k 80/tcp 443/tcp
3. Clearing Stale Routes (Optional)
If old project mappings or stale ephemeral ports remain in your routes table, you can wipe the user-level routes file:
rm -f "$HOME/.portless/routes.json"
4. Restarting the Proxy with Synchronized State
Restart the proxy daemon passing the explicit user state directory:
sudo PORTLESS_STATE_DIR="$HOME/.portless" portless proxy
Terminal output confirming clean restart:
[portless:proxy] Initializing reverse proxy on ports 80 (HTTP) and 443 (HTTPS)...
[portless:proxy] Watching state directory: $HOME/.portless
[portless:proxy] Loaded 0 active routes from $HOME/.portless/routes.json
✔ Proxy is running and listening for incoming localhost traffic.
Summary of Key Takeaways
- Explicit
appPort: Because Firebase Hosting emulator binds to a specific configured port (default5000, or custom e.g.5002), declare"portless": { "name": "<project>", "appPort": <port> }inpackage.json. - Speed Up Startup: Use
--only hostingin yournpm run devscript to avoid launching unnecessary emulators and prevent proxy timeouts. - Environment Alignment: Set
PORTLESS_STATE_DIR="$HOME/.portless"to ensure smooth inter-process routing between the privileged proxy daemon and user CLI sessions. - Clean Restarts: Use
sudo portless stoporsudo pkill -f "portless proxy"followed by restarting with the explicitPORTLESS_STATE_DIRto reload altered configurations.