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.1 and 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
ParameterMeaning
v4tov4Both sides are IPv4; v4tov6, v6tov4 and v6tov6 also exist
listenaddressLocal address to listen on: 127.0.0.1 for this machine only, 0.0.0.0 for every interface
listenportLocal port to listen on
connectaddressDestination address (IP or hostname)
connectportDestination 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

  1. TCP only. portproxy cannot forward UDP at all; if you need UDP, use socat inside WSL (next section).

  2. 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 iphlpsvc
    
    Set-Service -Name iphlpsvc -StartupType Automatic; Start-Service iphlpsvc
    
  3. “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:180 listens on local port 180;
  • bind=127.0.0.1 binds to loopback only, so nothing but this machine can connect; drop it to listen on every interface;
  • fork handles each incoming connection in its own child process — without it you serve exactly one connection and the process exits when it closes;
  • reuseaddr allows an immediate re-bind so restarts do not trip over TIME_WAIT;
  • TCP:10.0.0.1:80 is 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=yes runs the service as a transient unprivileged user — a forwarder has no business being root;
  • AmbientCapabilities=CAP_NET_BIND_SERVICE is only needed when the listen port is below 1024; drop the line otherwise;
  • Restart=always with RestartSec=2 reconnects 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:

  1. rdr only applies to traffic passing through the machine. Connections the Mac itself makes to 127.0.0.1 are not redirected, so the most common case — “hit 8080 locally, land on 80” — is exactly what it cannot do;
  2. 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

WindowsLinuxmacOS
Toolnetsh interface portproxy (built in)socat + systemdsocat + launchd
Install neededNoapt install socatbrew install socat
UDP❌ unsupported✅✅
Survives reboot✅ automatically (registry)needs systemctl enableneeds a LaunchDaemon
Depends onIP Helper service—Homebrew
Check port usagenetstat -ano | findstr :portss -tulnplsof -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 to 0.0.0.0 when 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-*'.