StackPanel

Deterministic Ports

Stable, conflict-free port assignments computed from your project name

Every Stackpanel project gets a stable set of port numbers derived from the project name. The same project always gets the same ports—on every machine, for every team member, with zero manual configuration.

The Problem

Run two projects locally that both want PostgreSQL on port 5432 and you get a conflict. The usual fix is to manually pick different ports for each project, write them down somewhere, and hope everyone on the team uses the same ones. This breaks constantly.

How It Works

Stackpanel computes ports using a deterministic hash of your project name:

  1. Hash the project name with MD5
  2. Convert the first 8 hex characters to a decimal number
  3. Constrain the result to the range [3000, 65000), rounded to the nearest 100
  4. Assign app ports sequentially from the base: base + 0, base + 1, base + 2, ...
  5. Assign service ports by hashing projectName + serviceName within the project's port range

Because the hash is deterministic, the same project name always produces the same ports. Two different projects will almost certainly get different port ranges, eliminating conflicts.

Example

For a project named "myapp", the computed ports might look like:

ServicePortEnvironment Variable
Web app4200STACKPANEL_WEB_PORT
API server4201STACKPANEL_API_PORT
PostgreSQL4237STACKPANEL_POSTGRES_PORT
Redis4252STACKPANEL_REDIS_PORT

Every developer on the team gets these exact same ports without any coordination.

Configuration

Ports are configured through stackpanel.ports:

stackpanel = {
  ports.projectName = "myapp";
};

That's it. The port assignments are computed automatically. You can reference the assigned ports through environment variables in your application code:

stackpanel = {
  apps.web = {
    port = 0;  # auto-assigned from the deterministic range
  };
  apps.api = {
    port = 1;  # next sequential port
  };
};

Environment Variables

Every assigned port is exported as an environment variable following the pattern:

STACKPANEL_<KEY>_PORT

These are available in your shell, in scripts, and in any process launched from the dev environment. Your application code can read them directly—no hardcoded port numbers anywhere.

Port environment variables are set during shell entry (the Nix evaluation phase), so they're available to all processes in the dev environment without any runtime resolution.

Why Not Just Use Random Ports?

Random ports solve conflicts but create a different problem: every time you restart your environment, everything gets a new address. Bookmarked URLs break, database connection strings change, and browser cookies stop working.

Deterministic ports give you the best of both worlds: no conflicts between projects, and stable addresses that never change.

Implementation

The algorithm is intentionally simple so you can reimplement it in any language. This is useful when external tools or scripts need to compute ports without access to Nix or the Stackpanel CLI.

The core function computeOverRange does all the work:

  1. MD5-hash the input key
  2. Take the first 4 hex characters (yields a value 0--65535)
  3. Modulo by the range size to constrain the result
  4. Round down to the nearest modulus
  5. Add the minimum port

Base port: computeOverRange(projectName, min=3000, max=10000, modulus=100)

Service port: computeOverRange(serviceKey, min=basePort, max=basePort+100, modulus=1)

import { createHash } from "node:crypto";

function computeOverRange(
  key: string,
  min: number,
  max: number,
  modulus: number,
): number {
  const hash = createHash("md5").update(key).digest("hex");
  const n = parseInt(hash.substring(0, 4), 16);
  const offset = n % (max - min);
  const rounded = offset - (offset % modulus);
  return min + rounded;
}

function basePort(projectName: string): number {
  return computeOverRange(projectName, 3000, 10000, 100);
}

function servicePort(projectName: string, serviceKey: string): number {
  const base = basePort(projectName);
  return computeOverRange(serviceKey, base, base + 100, 1);
}

// Example
console.log(basePort("myapp"));                    // e.g. 4200
console.log(servicePort("myapp", "POSTGRES"));     // e.g. 4237
package ports

import (
	"crypto/md5"
	"fmt"
	"strconv"
)

func computeOverRange(key string, min, max, mod int) int {
	h := md5.Sum([]byte(key))
	hex := fmt.Sprintf("%x", h)[:4]
	n, _ := strconv.ParseInt(hex, 16, 64)
	offset := n % int64(max-min)
	rounded := offset - (offset % int64(mod))
	return min + int(rounded)
}

func BasePort(projectName string) int {
	return computeOverRange(projectName, 3000, 10000, 100)
}

func ServicePort(projectName, serviceKey string) int {
	base := BasePort(projectName)
	return computeOverRange(serviceKey, base, base+100, 1)
}
import hashlib

def compute_over_range(key: str, min_port: int, max_port: int, modulus: int) -> int:
    h = hashlib.md5(key.encode()).hexdigest()
    n = int(h[:4], 16)
    offset = n % (max_port - min_port)
    rounded = offset - (offset % modulus)
    return min_port + rounded

def base_port(project_name: str) -> int:
    return compute_over_range(project_name, 3000, 10000, 100)

def service_port(project_name: str, service_key: str) -> int:
    base = base_port(project_name)
    return compute_over_range(service_key, base, base + 100, 1)

# Example
print(base_port("myapp"))                   # e.g. 4200
print(service_port("myapp", "POSTGRES"))    # e.g. 4237
compute_over_range() {
  local key="$1" min="$2" max="$3" mod="$4"
  local hash hex_part n range offset rounded

  hash=$(echo -n "$key" | md5sum | cut -c1-4)
  n=$((16#$hash))
  range=$((max - min))
  offset=$((n % range))
  rounded=$((offset - (offset % mod)))
  echo $((min + rounded))
}

base_port() {
  compute_over_range "$1" 3000 10000 100
}

service_port() {
  local base
  base=$(base_port "$1")
  compute_over_range "$2" "$base" $((base + 100)) 1
}

# Example
base_port "myapp"                   # e.g. 4200
service_port "myapp" "POSTGRES"     # e.g. 4237
# This is the actual implementation from nix/stackpanel/lib/ports.nix
{ lib }:
let
  computeOverRange = { key, min, max, modulus }:
    let
      range = max - min;
      rawHash = builtins.hashString "md5" key;
      hash = builtins.substring 0 4 rawHash;
      n = lib.trivial.fromHexString hash;
      offset = lib.mod n range;
      roundedOffset = offset - (lib.mod offset modulus);
    in
      min + roundedOffset;

  basePort = name:
    computeOverRange { key = name; min = 3000; max = 10000; modulus = 100; };

  servicePort = projectName: serviceKey:
    let base = basePort projectName;
    in computeOverRange { key = serviceKey; min = base; max = base + 100; modulus = 1; };
in
{
  inherit basePort servicePort computeOverRange;
}

All implementations must produce identical results for the same inputs. The parameters are fixed: MD5 hash, first 4 hex chars, min=3000, max=10000, modulus=100 for base ports, modulus=1 for service ports. If you reimplement this, test against the Nix or Go version to verify.

Reference

See the ports options reference for the full set of configurable options.

On this page