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:
- Hash the project name with MD5
- Convert the first 8 hex characters to a decimal number
- Constrain the result to the range
[3000, 65000), rounded to the nearest 100 - Assign app ports sequentially from the base:
base + 0,base + 1,base + 2, ... - Assign service ports by hashing
projectName + serviceNamewithin 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:
| Service | Port | Environment Variable |
|---|---|---|
| Web app | 4200 | STACKPANEL_WEB_PORT |
| API server | 4201 | STACKPANEL_API_PORT |
| PostgreSQL | 4237 | STACKPANEL_POSTGRES_PORT |
| Redis | 4252 | STACKPANEL_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>_PORTThese 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:
- MD5-hash the input key
- Take the first 4 hex characters (yields a value 0--65535)
- Modulo by the range size to constrain the result
- Round down to the nearest
modulus - 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. 4237package 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. 4237compute_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.