Local TCP/UDP port forwarding on all three platforms: Windows has it built in via netsh interface portproxy, while Linux and macOS use socat wrapped in a service so it survives reboots. A comparison table and a troubleshooting checklist are at the end.
The parameters are easy to get wrong, so there is a command generator below — fill in the addresses and ports and copy the result.
When you need port forwarding
The cases I run into most often:
- A service only listens on
127.0.0.1and you want other devices on the LAN to reach it; - A VPN grabbed every route (Cisco AnyConnect ships with split tunnel disabled, for example), so the machine can no longer see LAN devices and has to borrow a host that still can;
- A service running inside WSL2 that you want to reach from the Windows host or the LAN, while the WSL IP changes on every restart;
- The port you want is taken or fixed by convention, and you need to serve the same thing on a different one.
Keep two things apart: everything here is host-level port forwarding (a userspace proxy) — an incoming connection is handed to another address and port as-is. Making the machine act as a gateway that forwards on behalf of a whole subnet is NAT instead: on Linux that means DNAT with nftables/iptables plus net.ipv4.ip_forward, which is out of scope for this post.
🧰 Command generator
Multiple rules at a time are supported. Switch tabs for the platform you need; every block has a copy button in its top-right corner.
The generator runs entirely in your browser — nothing you type is sent anywhere. It also lives on its own page: Port Forwarding Command Generator.
Windows: netsh interface portproxy
Nothing to install: the built-in netsh interface portproxy is enough, and rules are stored in the registry, so they survive a reboot. Every command below needs an elevated PowerShell or CMD.
Create a rule
netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=1085 connectaddress=100.122.123.54 connectport=1085
| Parameter | Meaning |
|---|---|
v4tov4 | Both sides are IPv4; v4tov6, v6tov4 and v6tov6 also exist |
listenaddress | Local address to listen on: 127.0.0.1 for this machine only, 0.0.0.0 for every interface |
listenport | Local port to listen on |
connectaddress | Destination address (IP or hostname) |
connectport | Destination port |
List and delete
netsh interface portproxy show all
netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=1085
The listenaddress on delete must match exactly what you used on add — deleting a 127.0.0.1 rule with 0.0.0.0 reports that the rule does not exist. To wipe every rule at once:
netsh interface portproxy reset
Opening it to the LAN means a firewall rule
If listenaddress is 0.0.0.0 or a specific interface IP, Windows Firewall still has to allow the inbound connection:
netsh advfirewall firewall add rule name="portproxy 8080" dir=in action=allow protocol=TCP localport=8080
Three common traps
TCP only.
portproxycannot forward UDP at all; if you need UDP, usesocatinside WSL (next section).It depends on the “IP Helper” service. Plenty of trimmed-down images and “optimizer” scripts disable
iphlpsvc, and then rules can be added but do nothing. Check and start it:sc query iphlpsvcSet-Service -Name iphlpsvc -StartupType Automatic; Start-Service iphlpsvc“The requested address is not valid in its context.” Usually the listen port falls inside a range dynamically reserved by Hyper-V/WSL; pick another port. To see the reserved ranges:
netsh interface ipv4 show excludedportrange protocol=tcp
While we are here, checking what holds a port:
netstat -ano | findstr :8080
tasklist | findstr <the PID from the previous command>
Linux: socat
Linux has no direct equivalent of “configure it once and it stays”; the handiest option is socat — one command per forward, with systemd keeping it alive.
Install
sudo apt update && sudo apt install -y socat
Use sudo dnf install -y socat on RHEL-family distros, apk add socat on Alpine.
Run it with a single command
socat TCP-LISTEN:180,bind=127.0.0.1,reuseaddr,fork TCP:10.0.0.1:80
TCP-LISTEN:180listens on local port 180;bind=127.0.0.1binds to loopback only, so nothing but this machine can connect; drop it to listen on every interface;forkhandles each incoming connection in its own child process — without it you serve exactly one connection and the process exits when it closes;reuseaddrallows an immediate re-bind so restarts do not trip overTIME_WAIT;TCP:10.0.0.1:80is the destination.
For UDP, do not just swap in UDP-LISTEN — the RECVFROM/SENDTO pair is the right idiom:
socat -T30 UDP4-RECVFROM:5353,bind=127.0.0.1,reuseaddr,fork UDP4-SENDTO:10.0.0.1:5353
-T30 reaps idle sessions after 30 seconds; UDP is connectionless, so a timeout is the only cleanup mechanism.
Make it a systemd service
Recommended: one unit per forwarding rule. If one forward dies only that one restarts, systemctl status tells you instantly which one broke, and you can stop them individually.
sudo tee /etc/systemd/system/socat-180.service >/dev/null <<'EOF'
[Unit]
Description=socat forward 127.0.0.1:180 -> 10.0.0.1:80
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
ExecStart=/usr/bin/socat TCP-LISTEN:180,bind=127.0.0.1,reuseaddr,fork TCP:10.0.0.1:80
Restart=always
RestartSec=2
DynamicUser=yes
NoNewPrivileges=yes
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
EOF
A few notes:
DynamicUser=yesruns the service as a transient unprivileged user — a forwarder has no business being root;AmbientCapabilities=CAP_NET_BIND_SERVICEis only needed when the listen port is below 1024; drop the line otherwise;Restart=alwayswithRestartSec=2reconnects automatically when the destination is briefly unavailable.
Load it and enable it at boot:
sudo systemctl daemon-reload && sudo systemctl enable --now socat-180.service
For more rules, repeat the template with the file named socat-<port>.service and enable them together:
sudo systemctl enable --now socat-180.service socat-1899.service socat-9899.service
You can cram several into one unit (but don’t)
The version floating around the internet starts several socats with & in a single unit:
[Service]
ExecStart=/bin/bash -c "/usr/bin/socat TCP-LISTEN:180,bind=127.0.0.1,reuseaddr,fork TCP:10.0.0.1:80 & \
/usr/bin/socat TCP-LISTEN:9899,bind=127.0.0.1,reuseaddr,fork TCP:10.0.0.1:9899"
Restart=always
ExecStop=/bin/bash -c "pkill -f '/usr/bin/socat TCP-LISTEN'"
It works, but it has two clear drawbacks: systemd cannot see an individual forward die (as long as the foreground one lives, the unit stays active), and pkill -f in ExecStop kills every socat TCP-LISTEN process on the box, including ones this unit never started. Past a single rule, use the one-unit-per-rule form above.
Inspect and troubleshoot
systemctl status socat-180.service
sudo ss -tulnp | grep :180
journalctl -u socat-180.service -e
The two you will see most are Address already in use (port taken) and Connection refused (nothing listening on the far side); check the two ends separately with ss and nc -vz 10.0.0.1 80.
macOS
socat is the recommendation here too — installed with Homebrew, kept alive by launchd.
socat + launchd
brew install socat
Verify it by hand first (Ctrl+C to stop):
socat TCP-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork TCP:10.0.0.1:80
Once that works, turn it into a LaunchDaemon:
sudo tee /Library/LaunchDaemons/local.socat.8080.plist >/dev/null <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>local.socat.8080</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/socat</string>
<string>TCP-LISTEN:8080,bind=127.0.0.1,reuseaddr,fork</string>
<string>TCP:10.0.0.1:80</string>
</array>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
</dict>
</plist>
EOF
sudo launchctl bootstrap system /Library/LaunchDaemons/local.socat.8080.plist
Mind the socat path: /opt/homebrew/bin/socat on Apple silicon, /usr/local/bin/socat on Intel. Get it wrong and launchd fails silently — sudo launchctl print system/local.socat.8080 shows the exit status.
To remove it:
sudo launchctl bootout system/local.socat.8080 && sudo rm /Library/LaunchDaemons/local.socat.8080.plist
The built-in pf (sharp edges, optional reading)
macOS ships the pf firewall, whose rdr rules can redirect ports:
echo "rdr pass inet proto tcp from any to any port 8080 -> 127.0.0.1 port 80" | sudo pfctl -Ef -
Two limitations you have to know about:
rdronly applies to traffic passing through the machine. Connections the Mac itself makes to127.0.0.1are not redirected, so the most common case — “hit 8080 locally, land on 80” — is exactly what it cannot do;- Loading rules this way replaces the current pf ruleset. The proper approach is a file under
/etc/pf.anchors/referenced as an anchor.
Bottom line: use socat for everyday needs and keep pf for “this Mac is a gateway forwarding on behalf of other devices”.
And for checking what holds a port on macOS (there is an older post with more detail: managing ports on Mac, in Chinese):
sudo lsof -i tcp:8080
Picking between the three
| Windows | Linux | macOS | |
|---|---|---|---|
| Tool | netsh interface portproxy (built in) | socat + systemd | socat + launchd |
| Install needed | No | apt install socat | brew install socat |
| UDP | ❌ unsupported | ✅ | ✅ |
| Survives reboot | ✅ automatically (registry) | needs systemctl enable | needs a LaunchDaemon |
| Depends on | IP Helper service | — | Homebrew |
| Check port usage | netstat -ano | findstr :port | ss -tulnp | lsof -i tcp:port |
For a one-off forward across machines there is an easier option — an SSH tunnel. Local 8080 to 10.0.0.1:80 as seen from a jump host, one command, same on all three platforms:
ssh -N -L 127.0.0.1:8080:10.0.0.1:80 user@jump-host
A word on security
Port forwarding exists to bypass network isolation, so:
- Default to
bind=127.0.0.1/listenaddress=127.0.0.1, and only widen to0.0.0.0when the LAN genuinely needs access; - If the forwarded service has no authentication (admin panels, databases, Redis and friends), opening the listen address hands it to everyone on the network;
- Do not expose internal services through a public server unless there is authentication and TLS in front;
- Rules stick around. Keep track of what you added and prune it now and then with
netsh interface portproxy show all/systemctl list-units 'socat-*'.
Related posts
- Sharing Cisco AnyConnect with your LAN (Chinese)
- Checking and freeing ports on Mac (Chinese)