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:

  1. Multiple Co-existing Ports: Firebase spins up several emulators simultaneously (Hosting on 5000, Functions on 5001, Firestore on 8080, Auth on 9099, and the Emulator UI on 4000).
  2. Fixed Port Defaults: The Firebase CLI does not automatically adapt its Hosting emulator port to arbitrary environment variables like PORT=34567 provided by automatic reverse proxies.
  3. 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.
  4. 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:

  1. The Reverse Proxy Daemon: Needs to bind to privileged ports (80 for HTTP and 443 for HTTPS), requiring root privileges (sudo portless proxy or a system daemon).
  2. 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, and description.
  • Add the portless configuration block:
    • portless.name: Assigns the local hostname https://iosltd.localhost/. If omitted, the top-level name value is used.
    • portless.appPort: Points the reverse proxy directly to the internal port of your Firebase emulator (5000, or whatever port is set in firebase.json).
  • scripts.dev: Runs the Firebase Hosting emulator exclusively. This is the command portless runs 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

  1. Explicit appPort: Because Firebase Hosting emulator binds to a specific configured port (default 5000, or custom e.g. 5002), declare "portless": { "name": "<project>", "appPort": <port> } in package.json.
  2. Speed Up Startup: Use --only hosting in your npm run dev script to avoid launching unnecessary emulators and prevent proxy timeouts.
  3. Environment Alignment: Set PORTLESS_STATE_DIR="$HOME/.portless" to ensure smooth inter-process routing between the privileged proxy daemon and user CLI sessions.
  4. Clean Restarts: Use sudo portless stop or sudo pkill -f "portless proxy" followed by restarting with the explicit PORTLESS_STATE_DIR to reload altered configurations.