Every networking developer eventually runs into a strange inconsistency. You can bind a socket to port 0 and the operation succeeds. But if you try to connect to port 0, the connection is refused. The same number appears in SSH remote forwarding commands like ssh -R0:localhost:3000 free.pinggy.io, and it somehow tells the remote server to choose a free port. Port 0 is reserved, meaning it is not supposed to exist on the wire, yet it is one of the most useful values in the entire sockets API. This post explains what port 0 actually means, why the kernel and the SSH server treat it as a request to pick a port, and how you can use this trick in real development workflows.
I ran a few small experiments on a Linux 6.18 box to see where that convention holds, where it breaks, and what it has to do with tunnels. All output shown below is real unless it says otherwise. Pinggy relies on this exact behaviour for its TCP tunnels, so understanding port 0 helps you understand how tools like this work under the hood.
Summary
Here is what you need to remember from this article:
- In the sockets API,
bind(0)asks the kernel for a free ephemeral port. You read the port back withgetsockname(). On Linux the default pool is 32768-60999. - On the wire, port 0 is reserved. TCP has no use for it, and trying to connect to it fails. UDP has one oddity: RFC 768 lets a sender put 0 in the source port when it does not want a reply.
-
ssh -R0:...uses the same trick on a remote machine. The SSH protocol lets the client ask the server to pick the listening port and report it back, which is how a Pinggy TCP tunnel gets its port. - The practical rule: ask for port 0 when you do not care which port you get, then read the real port back before you tell anyone else about it.
What bind(0) Does
Binding a socket normally means listen on this exact port. Passing 0 changes the question to give me any free one. The kernel picks a port from its ephemeral range, binds the socket to it, and you ask the socket afterwards what it got.
Python shows it in four lines:
import socket
s = socket.socket()
s.bind(('127.0.0.1', 0))
print(s.getsockname())
The output on my machine was:
('127.0.0.1', 60681)
Run it again and you get a diferent number. The second socket I opened got 44627. Node.js does the same through listen(0):
const s = require('net').createServer().listen(0, '127.0.0.1', () => {
console.log('listening on', s.address().port)
})
Output:
listening on 44909
The range comes from a sysctl. On the machine I used:
cat /proc/sys/net/ipv4/ip_local_port_range
Output:
32768 60999
That is the Linux default. IANA's suggested ephemeral range in RFC 6335 is 49152-65535, and Windows and the BSDs follow that one, so the same program lands in a different part of the port space depending on the operating system.
The port number exists only after bind returns, so the program has to read it back. Why bother? Because hard-coded ports collide. Test suites that start a server per test, CI jobs that share a runner, and two dev servers that both want 3000 all trip over each other. Letting the kernel choose means nobody collides, since the kernel will not hand out a port that is already bound.
There is a catch: the port is only free at the moment you bind. Code that picks a port with bind(0), closes the socket, and then passes the number to another process has a race. Something else can take it in between. Keep the socket open and hand the process the socket, or have the server itself listen on 0 and report the port.
Inside the kernel, bind(0) triggers the ephemeral port allocator. The allocator scans the local port range, skips ports already in use by other sockets or reserved by listening services, and returns the first available candidate. Different kernels have different allocation strategies. Linux tends to allocate ports sequentially and wrap around, which makes large scale systems predictable. FreeBSD and macOS use a random offset to make it harder for remote attackers to guess the next port. This matters for security because predictable ephemeral ports can make certain spoofing attacks easier.
Why You Can't Connect to Port 0
The sockets convention is a local trick. Port 0 is still marked reserved in the IANA registry, and TCP does not use it. If you try to connect to it, you get an instant failure:
import socket
socket.create_connection(('127.0.0.1', 0), timeout=2)
Output:
ConnectionRefusedError: [Errno 111] Connection refused
A UDP sendto() to port 0 is rejected before it leaves the machine, with EINVAL (Invalid argument) on my run. So 0 is a valid argument to bind and an invalid destination for everything else.
The nuance is the UDP source port. RFC 768 calls the source port optional: it indicates the port of the sending process, and if it is not used, a value of zero is inserted. In practice it means do not reply to me. It is legal on the wire, and some firewalls drop it anyway. The IETF mailing lists have argued for years over whether reserved means forbidden or just unassigned; one FreeBSD thread covers the firewall angle, and an Austin Group bug report covers whether POSIX should specify the bind behaviour at all. POSIX itself does not say much on this subject.
For SCTP the rule is stricter: RFC 4960 section 3.1 forbids 0 as a source or destination port.
The takeaway: nobody should be listening on port 0 and nobody should be dialing it, and no real service will recieve your packets if you do. When you see traffic to port 0 in a firewall log, it is almost always a probe or fragments that lost their port information, not a service.
At the packet level, a TCP header with source or destination port 0 is not generated by normal kernels. The TCP stack treats port 0 as an invalid destination and returns ECONNREFUSED without sending a SYN. That is why the failure is immediate. UDP is different. The kernel checks the destination port locally and rejects the sendto call with EINVAL before the packet hits the wire. Destination port 0 is therefore not a network problem, it is a local policy decision built into the protocol stack.
The Other Port 0: ssh -R0
If you have used Pinggy, you have probably typed this:
ssh -p 443 -R0:localhost:3000 free.pinggy.io
The 0 in -R0 is the remote port to listen on, and it is the same idea as bind(0). The format is -R [bind_address:]port:host:hostport, so -R0:localhost:3000 reads as listen on any free port on the server, and send what arrives to localhost:3000 on my machine.
The SSH protocol has this built in. In RFC 4254, section 7.1, the client sends a tcpip-forward request, and if the port to bind is 0 and the client asked for a reply, the server allocates the next available unprivileged port and returns it. The client then knows where the forward lives.
With a plain OpenSSH server, you can see it directly. This is OpenSSH's own behaviour, not something that is specific to Pinggy:
Allocated port 41873 for remote forward to localhost:3000
OpenSSH prints that line on stderr when the server picks the port. With a Pinggy HTTP tunnel the port is hidden behind a hostname, so you get a URL such as https://abc123.a.pinggy.link. For a TCP tunnel you get the host and port itself:
ssh -p 443 -R0:localhost:22 tcp@free.pinggy.io
Pinggy prints a tcp:// address with a port the server allocated, and that port is what a friend types into their SSH client. With -R0 the Pinggy server picks the public port and tells the client which one it got.
The key detail is that SSH does not use the TCP port 0 destination mechanism. The SSH protocol is a control protocol with its own message types. Port 0 inside the SSH request is interpreted by the SSH server as a special value, not as a network destination. This is why port 0 can mean pick one for me in SSH even though it is invalid on the wire.
When the server receives the tcpip-forward request, it begins listening on the requested interface. If the requested port is 0, it asks its own ephemeral port allocator for a free number. The server then sends a success response that contains the allocated port. The client can use that number later when it opens data channels. The same pattern appears in tools like ssh -R and in tunnelling services like Pinggy.
I could not run the two Pinggy commands from the sandbox I wrote this in, because the sandbox has no ssh client. The output of those commands is therefore untested here, but the command forms match the Pinggy documentation.
Port 0 is not limited to remote forwarding. You can also use it with -L to make the local SSH client choose a free local listening port. The client prints a message similar to the remote one. The important idea is that port 0 is a signal to the SSH machinery, not a literal port number.
Using Both Zeros in One Workflow
Here is a worked example: a Node.js dev server picks its own port, and a tunnel points at whatever port it got.
First, create server.js:
const http = require('http')
const s = http.createServer((req, res) => res.end('hello'))
s.listen(0, '127.0.0.1', () => console.log(s.address().port))
Run the server in one terminal and read the port it prints:
node server.js
Output:
44909
Then start teh tunnel from another terminal:
ssh -p 443 -R0:localhost:44909 free.pinggy.io
Swap 44909 for the port your server printed. Two kinds of pick a port for me are now stacked: the kernel chose 44909 locally, and the Pinggy server chooses the public side. Neither number was in your config, and nothing had to be free in advance.
The main thing to watch is the local race from earlier. If you script this, have the server write the port to a file after it is listening, and start the tunnel after the file exists.
For a more robust script, server.js can write the port itself:
const http = require('http')
const fs = require('fs')
const s = http.createServer((req, res) => res.end('hello'))
s.listen(0, '127.0.0.1', () => {
fs.writeFileSync('port.txt', String(s.address().port))
})
Then the shell script can wait for the file:
node server.js &
until [ -s port.txt ]; do sleep 0.1; done
PORT=$(cat port.txt)
ssh -p 443 -R0:localhost:$PORT free.pinggy.io
This prevents the race because the server is already listening when the file is created. The tunnel also uses its own 0, so the public port is dynamic.
When you need a predictable public port for clients, a dynamic port can be inconvenient. Some tunnel services let you reserve a subdomain or a fixed port. Pinggy supports fixed ports on certain plans, but the free flow is designed around the zero trick.
Seeing It on Your Own Machine
Three quick checks, all of which work without root:
python3 - <<'PY'
import socket
s = socket.socket()
s.bind(('127.0.0.1', 0))
print(s.getsockname()[1])
PY
cat /proc/sys/net/ipv4/ip_local_port_range
On macOS, use:
sysctl net.inet.ip.portrange.first net.inet.ip.portrange.last
You can also inspect the ephemeral range on Linux with:
sysctl net.ipv4.ip_local_port_range
The output should match /proc/sys/net/ipv4/ip_local_port_range. Finally, to see which sockets are listening on your machine:
lsof -iTCP -sTCP:LISTEN -P -n
Run the Python snippet several times and watch the number change. Then try nc 127.0.0.1 0. The copy of netcat on my box rejected it before touching the network, printing nc: port number too small: 0, so the Python connection test above is a better way to demonstrate the failure.
Real World Patterns and Gotchas
The port 0 trick is not just an academic curiosity. It appears in many production tools and workflows.
Docker and Containers
Inside a container, listen(0) works exactly as it does on a host. The container kernel allocates an ephemeral port, and the process can read it back. However, if you publish a container port to the host, you generally need a known port for service discovery. Docker's -P flag publishes every exposed port to a random host port, which is a similar idea but not directly related to port 0. If you need a random host port with a fixed container port, you can run docker run -p 127.0.0.1::8080 ... and let Docker choose a host port.
CI Pipelines
CI systems often run multiple test jobs on the same physical machine. A test suite that binds to a fixed port can fail with EADDRINUSE when two jobs run at the same time. Using port 0 removes this class of failure. The challenge is that the test framework has to expose the chosen port. Some frameworks read it from the server object, while others require you to inspect the logs. Many load balancers and sidecars also need a predictable port, so the zero trick is most valuable in isolated environments.
Hot Reload and Dev Servers
Modern development servers can use port 0 to avoid the infamous EADDRINUSE error. The terminal prints the real port after startup. This is particularly useful when you have multiple projects open at the same time and you do not want to remember which port belongs to which project.
Ephemeral Port Exhaustion
Every time you bind to 0, you consume one entry from the ephemeral port range. If you do this many times without closing sockets, you can exhaust the available pool. This can happen in test suites that create thousands of sockets in a short time. On Linux, the default range contains roughly 28,000 ports. A busy server that also makes outbound connections shares the same pool. Use ss -s or netstat -s to monitor socket usage and port allocation. If you see failures like EADDRNOTAVAIL, you may have run out of ephemeral ports.
Firewalls and Security
Dynamic ports make firewall rules harder to write. If you know that a service always listens on port 3000, you can allow TCP 3000 in your security group. With port 0, you have no fixed port to allow, so you must either allow a wide range or use a service mesh that handles registration. For SSH tunnels, the remote side picks the port, which is fine for temporary sharing but impractical for a production API endpoint. A tunnel service like Pinggy manages the public port for you, so you do not need to update firewall rules on your laptop.
IPv6
The zero trick works with IPv6 as well. You can bind a socket to [::1]:0 and the kernel selects a port from the same ephemeral range. The address family does not change the semantics of port 0. The same ss or netstat commands will show the assigned port.
Orchestrators and Kubernetes
Kubernetes and other orchestrators usually do not allow port 0 in container port definitions. The port is part of the pod spec and must be a valid integer between 1 and 65535. A process inside the container can still bind to port 0 and later register itself with a service discovery agent, but the container port metadata cannot say 0. This can be a source of confusion when moving from a local test with port 0 to a deployment manifest.
Port 0 Across Languages
The port 0 convention is part of the sockets API, so every language that exposes sockets supports it. Here are a few examples.
Go
In Go, net.Listen accepts port 0 and the listener address contains the assigned port:
package main
import "net"
func main() {
ln, _ := net.Listen("tcp", "127.0.0.1:0")
println(ln.Addr().(*net.TCPAddr).Port)
}
Ruby
Ruby uses TCPServer.new with port 0:
require 'socket'
server = TCPServer.new('127.0.0.1', 0)
port = server.addr[1]
puts port
Rust
In Rust, you call local_addr to discover the port:
use std::net::TcpListener;
fn main() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
println!("{}", listener.local_addr().unwrap().port());
}
All of these use the same kernel feature. The language does not change the meaning of port 0; it only changes how you read the assigned port.
Port 0 in Web Frameworks
The same idea applies to high level web frameworks. Express in Node.js accepts 0 as the port parameter:
const app = require('express')()
app.get('/', (req, res) => res.send('hello'))
const server = app.listen(0, () => {
console.log(server.address().port)
})
In Python, the standard library http.server can run on port 0 if you create the server object directly:
from http.server import HTTPServer, BaseHTTPRequestHandler
import socket
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b'hello')
httpd = HTTPServer(('127.0.0.1', 0), Handler)
print(httpd.server_address[1])
httpd.handle_request()
The kernel assigns the port only after the server socket is created. The framework hands you the address object so you can discover the port.
Common Misunderstandings About Port 0
There are several confusing ideas about port 0 that show up in developer discussions.
Port 0 Means Disabled
Some configuration files use port 0 to mean do not listen. That is an application-level convention. In the sockets API, port 0 always means kernel, choose. A server program may treat 0 differently from the raw socket API, so check the documentation before assuming.
Port 0 Is a Wildcard
In bind(), port 0 is not a wildcard. The wildcard address is 0.0.0.0 for IPv4 and :: for IPv6. Port 0 is a request to allocate a concrete port. After the call, the port is no longer zero.
Port 0 Can Be Connected To
Some developers think that if a process binds to port 0, another process can connect to port 0 and reach it. That is false. The bound socket has a real ephemeral port, not port 0. A connection to port 0 is invalid and will fail before it reaches any listener.
Port 0 Is a Secret Port
Port 0 is reserved, not secret. If someone tells you to connect to port 0, they are probably talking about a dynamic port that needs to be discovered. Do not hard-code it.
Troubleshooting and Common Questions
Why does bind(0) return the same port twice?
If you close the first socket and then bind a second one immediately, the kernel may reuse the same port because it is the first free port in the sequence. This is not a bug. The kernel has no memory that you do not want the same number twice.
Can I put port 0 in a URL?
No. A URL like http://localhost:0/ is invalid for practical purposes. Browsers ignore the port or reject it. Port numbers in URLs must be between 1 and 65535.
Does port 0 work with UDP servers?
Yes. You can call bind() on a UDP socket with port 0 if you want the kernel to pick a source port. This is useful for clients that need a unique source port. The server will see a nonzero source port in the packet header after the kernel replaces the 0.
What happens if a TCP SYN arrives with destination port 0?
It should not happen. If it does, the receiving stack will almost certainly drop it. Some network monitoring tools may flag it as abnormal traffic. There is no service registered on port 0, so there is nothing to handle it.
How do I get the assigned port in a shell script?
If your server prints the port to stdout, capture the output. If it does not, write the port to a file. The file-based approach is safer because it avoids the race where the port is read before the server is listening.
Can ssh -R0 fail?
Yes. The remote SSH server must allow remote forwarding. OpenSSH's AllowTcpForwarding remote or PermitOpen settings can block it. The server may also have a restricted port range. If port 0 is not allowed, you will see an error such as remote port forwarding failed for listen port 0.
Why do some packets show source port 0 in UDP?
RFC 768 permits a sender to use source port 0 when a reply is not wanted. This happens in some network boot and discovery protocols. Many firewalls treat source port 0 as suspicious and drop the packet.
Is there a way to avoid the race when using bind(0)?
The safest approach is to keep the listening socket open in the same process that needs the port. If another process must know the port, pass the socket file descriptor over the process boundary using file descriptor passing. Alternatively, have the listening process write the port to a file or publish it to a service registry after the socket is bound.
Conclusion
Port 0 is not a port you use. It is a request. Locally it means kernel, choose. Remotely it means server, choose. In both cases the real number only shows up in the reply. If you write servers or test harnesses, listen(0) and a read-back cures most port clashes. If you tunnel, remember that the 0 in -R0 is why you did not have to pick a public port at all.
Understanding port 0 makes socket programming feel less magical. The reserved number is not a destination; it is a placeholder for the allocator. Once you understand that, you can read the real port back and move on with confidence.
References
- Port 0: The Port That Doesn't Exist
- RFC 768: User Datagram Protocol
- RFC 4254: The Secure Shell (SSH) Connection Protocol
- RFC 4960: Stream Control Transmission Protocol
- RFC 6335: Internet Assigned Numbers Authority (IANA) Procedures for the Management of the Service Name and Transport Protocol Port Number Registry


Top comments (0)