Port Forwarding
Port forwarding makes a remote service reachable as if it were local (SSH tunnels, Kubernetes pod forwarding), or the reverse — makes a local service reachable from a remote host (SSH reverse tunnels).
Overview
Port forwarding (packages/core/src/adapters/ssh/ssh-api.ts,
packages/core/src/adapters/kubernetes/kubernetes-api.ts) provides:
- SSH local tunneling — a remote
host:portreachable through a local port - SSH reverse tunneling — a local
host:portreachable through a port on the remote host - Kubernetes pod port forwarding — a pod's port reachable through a local port, with an optional OS-assigned local port
There is no SOCKS/dynamic forwarding, no jump-host chaining option, no connection multiplexing configuration, and no automatic reconnection — see What Isn't Supported.
SSH Port Forwarding
Local Port Forwarding
import { $ } from '@xec-sh/core';
const remote = $.ssh({ host: 'jump.server.com', username: 'user' });
const tunnel = await remote.tunnel({
remoteHost: 'database.internal',
remotePort: 3306,
// localPort omitted — the OS assigns one; pass a number to pin it
});
// Connect through the tunnel's actual local port
const db = await mysql.connect({
host: 'localhost',
port: tunnel.localPort,
user: 'dbuser',
password: 'dbpass'
});
// Close the tunnel when done
await tunnel.close();
tunnel.localPort, .localHost, .remoteHost, .remotePort and .isOpen
are readable at any time; .open() re-opens a tunnel that was closed.
Reverse Port Forwarding
// Make a local service reachable from the remote host
const tunnel = await remote.reverseTunnel({
remotePort: 8080,
localPort: 3000,
});
console.log('Local :3000 is now reachable on the remote host at :', tunnel.remotePort);
await tunnel.close();
The remote listener binds to 127.0.0.1 on the remote host by default —
pass remoteHost: '0.0.0.0' explicitly if you want it reachable from
outside that host, which is a deliberate, security-relevant choice rather
than the default.
There is no dynamic (SOCKS) forwarding — both calls forward exactly one
fixed host:port pair.
Kubernetes Port Forwarding
Port forwarding is a method on a specific pod, reached through .pod(name)
— not on the k8s context directly, and there is no way to target a
Kubernetes Service by name.
const pod = $.k8s('production').pod('database-pod');
const forward = await pod.portForward(5432, 5432); // local, remote
const client = new pg.Client({ host: 'localhost', port: forward.localPort });
await client.connect();
await forward.close();
To let the OS pick a free local port instead of choosing one yourself:
const forward = await pod.portForwardDynamic(5432);
console.log('Forwarded to local port', forward.localPort);
This shells out to kubectl port-forward, so kubectl must be on PATH
and configured for the target cluster. forward.localPort only resolves to
the real port once kubectl reports it has started forwarding — the
portForward/portForwardDynamic promise already waits for that, so it is
safe to read immediately after await.
Multiple Forwards
Neither SSH nor Kubernetes has a batch-forwarding helper — open each tunnel individually and track them yourself:
const forwards = await Promise.all([
remote.tunnel({ localPort: 3306, remoteHost: 'mysql.internal', remotePort: 3306 }),
remote.tunnel({ localPort: 6379, remoteHost: 'redis.internal', remotePort: 6379 }),
remote.tunnel({ localPort: 5432, remoteHost: 'postgres.internal', remotePort: 5432 }),
]);
// ... use the services ...
await Promise.all(forwards.map(t => t.close()));
The same pattern covers multiple ports on one pod:
const pod = $.k8s('monitoring').pod('monitoring-stack');
const [grafana, prometheus, alertmanager] = await Promise.all([
pod.portForward(3000, 3000),
pod.portForward(9090, 9090),
pod.portForward(9093, 9093),
]);
Security Considerations
// Local tunnel: bind the local end to loopback only (the default behavior
// of most tools, made explicit here)
const secure = await remote.tunnel({
localHost: '127.0.0.1',
remoteHost: 'database.internal',
remotePort: 3306,
});
// Reverse tunnel: bind the *remote* listener to loopback only — the actual
// default, shown explicitly. Passing '0.0.0.0' exposes your local service
// to the remote host's whole network and should be a deliberate choice.
const tunnel = await remote.reverseTunnel({
remoteHost: '127.0.0.1',
remotePort: 8080,
localPort: 3000,
});
Use Cases
Database Access
async function accessRemoteDB() {
const tunnel = await $.ssh({
host: 'bastion.example.com',
username: 'deploy'
}).tunnel({
remoteHost: 'postgres.private.vpc',
remotePort: 5432,
});
try {
const client = new pg.Client({
host: 'localhost',
port: tunnel.localPort,
database: 'production'
});
await client.connect();
const result = await client.query('SELECT * FROM users');
await client.end();
return result.rows;
} finally {
await tunnel.close();
}
}
Debugging a Remote Pod
async function debugPod(podName: string, namespace: string) {
const pod = $.k8s(namespace).pod(podName);
const [inspector, app, metrics] = await Promise.all([
pod.portForward(9229, 9229), // Node.js debugger
pod.portForward(3000, 3000), // Application
pod.portForward(9090, 9090), // Metrics
]);
console.log('Debug ports forwarded:');
console.log('- Debugger: chrome://inspect');
console.log('- Application: http://localhost:3000');
console.log('- Metrics: http://localhost:9090/metrics');
return { inspector, app, metrics };
}
What Isn't Supported
The tunnel and port-forward objects are deliberately small — .localPort,
.localHost, .remoteHost, .remotePort, .isOpen, .open() (SSH local
tunnels only), .close(). There is no:
- SOCKS/dynamic forwarding
- Jump-host chaining through a
proxyoption —SSHAdapterOptionshas no such field; tunnel to the first host, then connect through it, if you need a multi-hop path - SSH multiplexing options (
controlMaster/controlPath/controlPersist) - Compression options for tunnel traffic
- Forwarding a Kubernetes Service by name, rather than a specific pod
- Automatic reconnection, health checks, or
EventEmitter-style events (.on('ready'|'error'|'close', ...)) on a tunnel or port-forward object
Build any of these yourself on top of .tunnel()/.reverseTunnel()/
.portForward() — reconnecting on failure, for instance, is just calling
.close() and the same method again.
Error Handling
try {
const tunnel = await remote.tunnel({
localPort: 3000,
remoteHost: 'service.internal',
remotePort: 80,
});
} catch (error) {
console.error('Tunnel failed to open:', error.message);
// A fixed localPort already in use, or the remote host/port unreachable,
// both surface as a rejection here — there is no structured error code to
// switch on, only the message.
}
Automatic Port Selection
// .tunnel() does not retry or pick a free port itself if a fixed localPort
// is taken — retry with a different one, or omit localPort so the OS
// assigns one from the start
async function tunnelWithRetry(
remote: ReturnType<typeof $.ssh>,
config: { remoteHost: string; remotePort: number; localPort: number },
maxAttempts = 10
) {
for (let i = 0; i < maxAttempts; i++) {
try {
return await remote.tunnel({ ...config, localPort: config.localPort + i });
} catch (error) {
if (i === maxAttempts - 1) throw error;
}
}
}
Best Practices
Do's ✅
// ✅ Close tunnels when done
const tunnel = await remote.tunnel(config);
try {
await useService();
} finally {
await tunnel.close();
}
// ✅ Let the OS pick a local port when you don't need a specific one —
// avoids collisions, and works for both SSH and Kubernetes
const dbTunnel = await remote.tunnel({ remoteHost: 'db', remotePort: 3306 });
const podForward = await pod.portForwardDynamic(3306);
// ✅ Keep a reverse tunnel's remote listener on loopback unless you
// specifically need it reachable from elsewhere on that network
await remote.reverseTunnel({ remoteHost: '127.0.0.1', remotePort: 8080, localPort: 3000 });
Don'ts ❌
// ❌ Leave tunnels open
await remote.tunnel(config);
// Tunnel never closed
// ❌ Assume a fixed localPort is free
await remote.tunnel({ localPort: 80, remoteHost: 'db', remotePort: 3306 });
// May fail with the port already in use — prefer a dynamic port, or catch
// and retry with a different one
// ❌ Open far more tunnels than you'll use concurrently
for (let i = 0; i < 1000; i++) {
await remote.tunnel({ remoteHost: 'db', remotePort: 3306 });
}
Implementation Details
Port forwarding is implemented in:
packages/core/src/adapters/ssh/ssh-api.ts-.tunnel()/.reverseTunnel()on the SSH execution contextpackages/core/src/adapters/ssh/index.ts-SSHAdapter.tunnel()/.reverseTunnel()packages/core/src/adapters/kubernetes/kubernetes-api.ts-K8sPod.portForward()/.portForwardDynamic()packages/core/src/adapters/kubernetes/index.ts-KubernetesAdapter.portForward(), which shells out tokubectl port-forward