{"slug": "a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding", "title": "A Practical Guide to SSH Tunnels: Local and Remote Port Forwarding", "summary": "SSH tunnels enable secure access to internal network services through port forwarding. Local port forwarding allows a local machine to access a remote service via an SSH connection, while remote port forwarding exposes a local service to the internet. This guide provides practical labs and a visual cheat sheet for setting up SSH tunnels.", "body_md": "# A Practical Guide to SSH Tunnels: Local and Remote Port Forwarding\n\nSSH is [yet another example](https://iximiuz.com/en/posts/linux-pty-what-powers-docker-attach-functionality/) of an ancient technology that is still in wide use today.\nIt may very well be that learning a couple of SSH tricks is more profitable in the long run than mastering a dozen Cloud Native tools or AI agent frameworks destined to become deprecated next quarter.\n\nOne of my favorite parts of this technology is SSH Tunnels. With nothing but standard tools and often using just a single command, you can achieve the following:\n\n- Access internal VPC endpoints through a public-facing EC2 instance.\n- Open a\n`localhost`\n\nport of a remote development VM in the local browser. - Expose any local server from a home/private network to the outside world.\n[Tunnel your browser's debugging port to a remote sandboxed coding agent](/docs/playground-recipes/coding-agent-with-browser-access).\n\nAnd more 😍\n\nBut despite the fact that I use SSH Tunnels daily, it always takes me a while to recall the right command.\nShould it be a Local or a Remote tunnel? What are the flags? Is it a *local_port:remote_port* or the other way around?\nSo, I decided to finally wrap my head around it, and it resulted in a series of labs and a visual cheat sheet.\n\nThe labs in this tutorial run on an attached playground with four hosts wired into three networks:\n\n`internal`\n\n- a device on the**home** network`192.168.0.0/24`\n\n(a homelab box, a NAS, a printer). Not reachable from the**public** network.`local`\n\n- your workstation. Sits on both the**home**`192.168.0.0/24`\n\nand the**public**`203.0.113.0/24`\n\nnetworks.`remote`\n\n- a public-facing bastion / gateway on the**public**`203.0.113.0/24`\n\nnetwork, also connected to a private**vpc**`172.16.0.0/24`\n\n.`private`\n\n- an internal-only service (a database, an OpenSearch cluster) on the**vpc**`172.16.0.0/24`\n\n. Not reachable from the**public** network.\n\nYou can `ssh`\n\nfrom `local`\n\nto `remote`\n\nby hostname or IP address - the `local`\n\nhost key is already trusted on the `remote`\n\nmachine:\n\n```\nssh remote\nssh 203.0.113.30\n```\n\n## Local Port Forwarding\n\nStarting from the one that I use the most.\nOftentimes, there might be a service listening on `localhost`\n\nor a private interface of a remote machine that I can only SSH to via its public IP.\nAnd I desperately need to access this port from my local machine. A few typical examples:\n\n- Accessing a private remote database (MySQL, Postgres, Redis, etc) from your laptop using your favorite UI tool.\n- Using your browser to access a web application exposed only to a private network.\n- Accessing a container's port from your laptop without publishing it on the server's public interface.\n\nAll of the above use cases can be solved with a single `ssh`\n\ncommand:\n\n```\nssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr\n```\n\nThe `-L`\n\nflag indicates we're starting a *local port forwarding*. What it actually means is:\n\n- On your local machine, the SSH client will start listening on\n`local_port`\n\n(likely, on`localhost`\n\n, but*it depends*-[check the](https://linux.die.net/man/5/sshd_config#GatewayPorts)).`GatewayPorts`\n\nsetting - Any traffic to this port will be forwarded to\n`remote_addr:remote_port`\n\n, reached from the remote machine you SSH-ed to.\n\nHere is what it looks like on a diagram:\n\n**Pro Tip:** Use `ssh -f -N -L`\n\nto run the port-forwarding session in the background.\n\n## Lab 1: Using SSH Tunnels for Local Port Forwarding 👨🔬\n\nThis lab reproduces the setup from the diagram above.\nThe `remote`\n\nhost runs a web server bound to `127.0.0.1:80`\n\n, and we want to reach it from the `local`\n\nworkstation.\n\nBecause the service is bound to the loopback interface, it cannot be reached over the network.\nFrom the local host, try to hit the `remote`\n\nhost's public address:\n\n```\ncurl 203.0.113.30:80  # remote.public\ncurl: (7) Failed to connect to 203.0.113.30 port 80 after 0 ms: Could not connect to server\n```\n\nBut from the inside of the remote host, the very same service works just fine:\n\n```\ncurl localhost:80\nHello from the remote host (localhost-only service).\n```\n\n**And here is the trick:** back on the local host,\nbind the remote's `localhost:80`\n\nto the local's `localhost:8080`\n\nusing local port forwarding:\n\n```\nssh -f -N -L 8080:localhost:80 203.0.113.30\n```\n\nNow you can access the web service on a local port of your workstation:\n\n```\ncurl localhost:8080\nHello from the remote host (localhost-only service).\n```\n\nA slightly more verbose (but more explicit and flexible) way to achieve the same goal:\n\n```\nssh -f -N -L localhost:8080:localhost:80 203.0.113.30\n#            local          remote       via\n```\n\n## Local Port Forwarding with a Bastion Host\n\nIt might not be obvious at first, but the `ssh -L`\n\ncommand allows forwarding a local port to a remote port on *any machine*,\nnot only on the SSH server itself. Notice how the `remote_addr`\n\nand `sshd_addr`\n\nmay or may not have the same value:\n\n```\nssh -L [local_addr:]local_port:remote_addr:remote_port [user@]sshd_addr\n```\n\nA remote SSH server used to access private destinations is usually called a [ bastion or jump host](https://en.wikipedia.org/wiki/Bastion_host).\nThis is how I visualize this scenario in my head:\n\nI often use the above trick to call endpoints that are accessible from the *bastion host* but not from my laptop\n(e.g., using an EC2 instance with private and public interfaces to connect to an OpenSearch cluster or any other service deployed fully within a VPC).\n\n## Lab 2: Local Port Forwarding with a Bastion Host 👨🔬\n\nThis lab reproduces the setup from the diagram above.\nThe remote target service runs on the `private`\n\nhost inside an improvised VPC network (`172.16.0.40:80`\n\n),\nand the former `remote`\n\nhost acts as our public-facing bastion (jump host) that can reach it.\n\nThe `local`\n\nworkstation has no route into the VPC,\nso it cannot talk to the `private`\n\nhost directly.\nFrom the local host:\n\n```\ncurl --connect-timeout 3 172.16.0.40:80  # private.vpc\ncurl: (28) Connection timed out after 3002 milliseconds\n```\n\nThe `remote`\n\nbastion, on the other hand, is connected to the VPC and can reach the `private`\n\nhost.\nSo, we forward a local port through the bastion straight to the private service.\nFrom the local host:\n\n```\nssh -f -N -L 8081:172.16.0.40:80 203.0.113.30\n```\n\nChecking that it works - still on the local host:\n\n```\ncurl localhost:8081\nHello from the private VPC host (172.16.0.40).\n```\n\n**Notice that the forwarding target ( 172.16.0.40) and the SSH server (203.0.113.30) are different machines.**\nThe bastion accepts the connection and opens the second hop to the private host on our behalf.\n\nA slightly more verbose (but more explicit and flexible) way to achieve the same goal:\n\n```\nssh -f -N -L localhost:8081:172.16.0.40:80 203.0.113.30\n#            local          remote         via\n```\n\n## Remote Port Forwarding\n\nAnother popular (but rather inverse) scenario is when you want to momentarily expose a local service to the outside world.\nOf course, for that, you'll need a *public-facing ingress gateway server*.\nAnd the good news is that any public-facing server with an SSH daemon on it can be used as such a gateway:\n\n```\nssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr\n```\n\nThe above command looks no more complicated than its `ssh -L`\n\ncounterpart. But there is a pitfall...\n\n**By default, the above SSH tunnel will allow using only the gateway's localhost as the remote address.**\nIn other words, your local port will become accessible only from inside the gateway server itself,\nwhich is most likely not what you actually need.\nFor instance, I typically want to use the gateway's public address as the remote address to expose my local services to the public Internet.\nFor that, the SSH server needs to be configured with the [ GatewayPorts yes](https://linux.die.net/man/5/sshd_config#GatewayPorts) setting.\n\nHere is what remote port forwarding can be used for:\n\n- Exposing a dev service from your laptop to the public Internet for a quick demo.\n- Exposing your homelab to the public Internet (for arbitrary purposes).\n[Tunneling your local browser's debugging port to a remote and/or sandboxed coding agent](/docs/playground-recipes/coding-agent-with-browser-access).\n\nHere is how the remote port forwarding can be visualized:\n\n**Pro Tip:** Use `ssh -f -N -R`\n\nto run the port-forwarding session in the background.\n\n## Lab 3: Using SSH Tunnels for Remote Port Forwarding 👨🔬\n\nThis lab reproduces the setup from the diagram above.\nThe `local`\n\nworkstation runs a web server bound to `127.0.0.1:80`\n\n,\nand we want to expose it to the outside through the public-facing `remote`\n\ngateway.\n\nThe service is bound to the loopback interface, so right now nobody but the `local`\n\nmachine itself can reach it.\nTry accessing it from the remote machine:\n\n```\ncurl --connect-timeout 3 203.0.113.20:80  # local.public\ncurl: (7) Failed to connect to 203.0.113.20 port 80 after 0 ms: Could not connect to server\n```\n\nWe want to expose it through the `remote`\n\ngateway and consume it from the `private`\n\nhost.\nThe `remote`\n\ngateway already has `GatewayPorts yes`\n\nin its `sshd_config`\n\n,\nso we can ask it to listen on all of its interfaces (`0.0.0.0`\n\n) and forward the traffic back to us.\n**However, the local machine has to establish the tunnel first**.\n\nFrom the local host, start the remote port forwarding:\n\n```\nssh -f -N -R 0.0.0.0:8080:localhost:80 203.0.113.30\n#            remote       local        via\n```\n\nNow the local web service is published on the gateway's interfaces.\nLet's confirm it from a *third* machine - the private host,\nwhich can reach the `remote`\n\ngateway over the VPC:\n\n```\ncurl 172.16.0.30:8080  # remote.vpc\nHello from your local workstation (localhost-only service).\n```\n\n## Remote Port Forwarding to a Home or Private Network\n\nSimilar to local port forwarding, remote port forwarding has its own *bastion or jump host* mode.\nBut this time, the machine with the SSH client (e.g., your dev laptop) plays the role of the jump host.\nIn particular, it allows exposing ports of a home (or private) network reachable from your laptop\nto the outside world through a remote SSH server acting as an ingress gateway:\n\n```\nssh -R [remote_addr:]remote_port:local_addr:local_port [user@]gateway_addr\n```\n\nLooks almost identical to the simple remote SSH tunnel,\nbut the `local_addr:local_port`\n\npair becomes the address of a device in the home network.\nHere is how it can be depicted on a diagram:\n\nI typically use my laptop as a thin client and the actual development happens on a remote server. Sometimes, such a remote server can reside in my home network and have no or restricted Internet access (for extra isolation). This is when I may want to rely on remote port forwarding to expose a service from a home server to the public Internet, using my laptop that can access both the internal dev server and the remote SSH server (ingress gateway) as a jump host.\n\n## Lab 4: Remote Port Forwarding from a Home/Private Network 👨🔬\n\nThis lab reproduces the setup from the diagram above.\nThe service we want to expose runs on the `internal`\n\nhost inside an isolated home network (`192.168.0.10:80`\n\n).\nOur `local`\n\nworkstation can reach the home network and also has SSH access to the public-facing `remote`\n\ngateway,\nso it plays the role of a jump host.\n\nThe `local`\n\nhost can reach the `internal`\n\nservice over the home network.\nFrom the local host:\n\n```\ncurl 192.168.0.10:80  # internal.home\nHello from the internal home-network host (192.168.0.10).\n```\n\nFrom the outside, though, the `internal`\n\ndevice is invisible.\nTry accessing it from the remote host:\n\n```\ncurl --connect-timeout 3 192.168.0.10:80  # internal.home\ncurl: (28) Connection timed out after 3001 milliseconds\n```\n\nThe `remote`\n\nhost has no route into the home network, so the request simply times out.\n\nNow, from the local host,\n**start the remote port forwarding from the remote gateway to the internal device**.\nThe forwarding target (\n\n`192.168.0.10`\n\n) is resolved by the SSH client, i.e., from the `local`\n\nhost's point of view:\n\n```\nssh -f -N -R 0.0.0.0:8081:192.168.0.10:80 203.0.113.30\n#            remote       local           via\n```\n\nFinally, validate that the home-network service became accessible on the gateway - from the private host, which reaches the gateway over the VPC:\n\n```\ncurl 172.16.0.30:8081  # remote.vpc\nHello from the internal home-network host (192.168.0.10).\n```\n\n## Dynamic Local Port Forwarding\n\nThis forwarding mode is less transparent for the clients,\nbut it is also significantly more flexible than regular local port forwarding.\nInstead of wiring a local port to a single remote destination (like `ssh -L`\n\ndoes),\n**dynamic (local) port forwarding** turns the SSH client into a local [SOCKS proxy](https://en.wikipedia.org/wiki/SOCKS).\nAny application that can speak SOCKS can then send traffic through it,\nchoosing the actual destination host and port *per connection* -\nthey will be sent over to the SSH server, which will resolve the destination and establish the connection:\n\n```\nssh -D [local_addr:]local_port [user@]sshd_addr\n```\n\nWhen the `-D`\n\nflag is used, the SSH client on your machine starts a SOCKS proxy listening on `local_port`\n\n(on `localhost`\n\nby default).\nEach connection made through the proxy is forwarded to whatever address the SOCKS client asks for, reached from the `sshd_addr`\n\nmachine.\n\nIn other words, it's like `ssh -L`\n\n, but you don't have to specify a single `remote_addr:remote_port`\n\nupfront,\nbecause the SOCKS protocol allows specifying the destination at the beginning of each connection\n(via a few extra bytes sent right before the payload).\nOne (local) proxied port gives you access to *every* host and port reachable from the (remote) SSH server.\n\nHere is what dynamic port forwarding can be used for:\n\n- Calling APIs in a private network through a bastion, without a separate tunnel per service.\n- Browsing internal web apps in a remote network via a single jump host.\n- Reaching a fleet of VPC endpoints from your laptop through one EC2 instance.\n\n**Pro Tip:** Use `ssh -f -N -D`\n\nto run the SOCKS proxy in the background.\n\n## Lab 5: Using SSH Tunnels for Dynamic Port Forwarding 👨🔬\n\nThis is the bastion scenario from Lab 2 again, except this time we won't pin the tunnel to a single destination.\n\nFirst, let's make sure we cannot reach the `private`\n\ndestination from the local machine:\n\n```\ncurl --connect-timeout 3 172.16.0.40:80  # private.vpc\ncurl: (28) Connection timed out after 3002 milliseconds\n```\n\nNow, on the local host, start a SOCKS proxy through the `remote`\n\nhost:\n\n```\nssh -f -N -D 1080 203.0.113.30  # remote.public\n```\n\nIf you point `curl`\n\nat the proxy to reach the `private`\n\nVPC service, the request will come through:\n\n```\ncurl --socks5-hostname localhost:1080 172.16.0.40:80\n#                      via            private.vpc\nHello from the private VPC host (172.16.0.40).\n```\n\nNote that unlike with `ssh -L`\n\n, the client - curl in this case - must be able to speak SOCKS (see the `--socks5-hostname`\n\nflag).\n\nThe same SOCKS proxy reaches *any* host the `remote`\n\nmachine can - including a *second* VPC host -\nwithout setting up a separate tunnel, try reaching the `private-2`\n\nmachine:\n\n```\ncurl --socks5-hostname localhost:1080 172.16.0.50:80\n#                      via            private-2.vpc\nHello from the second private VPC host (172.16.0.50).\n```\n\nWith `ssh -L`\n\n, reaching both private hosts would have meant two separate tunnels (one per `remote_addr:remote_port`\n\n).\nA single `ssh -D`\n\nproxy covers the whole network behind the bastion.\n\n## Dynamic Remote Port Forwarding\n\nJust like `ssh -L`\n\nhas a dynamic sibling in `ssh -D`\n\n, the `ssh -R`\n\ncommand has its own dynamic mode.\nIf you drop the fixed destination from `-R`\n\nand pass only a port, OpenSSH turns the **SSH server** itself into a SOCKS proxy.\nIt's the exact mirror of `-D`\n\n: this time the proxy lives on the gateway,\nand every connection made through it is tunneled back to the `ssh`\n\nclient and resolved from *its* point of view:\n\n```\nssh -R [bind_address:]port [user@]gateway_addr\n```\n\nThe `-R`\n\nflag **with no destination means**:\n\n- On the remote gateway, the SSH server starts a SOCKS proxy listening on\n`port`\n\n(on the gateway's`localhost`\n\nby default, or on all interfaces with`GatewayPorts yes`\n\n). - Each connection made through the proxy is tunneled back to the\n`ssh`\n\nclient and forwarded to whatever address the SOCKS client asks for, reached from the client's side.\n\nIt's like a regular `ssh -R`\n\n, but you don't have to choose a single `local_addr:local_port`\n\nupfront.\nOne proxy on the gateway exposes *every* host and port reachable from the `ssh`\n\nclient - for example, an entire home network.\n\nRemote dynamic forwarding requires OpenSSH 7.6 or newer on the client.\nAs with a regular `ssh -R`\n\n, binding the proxy to a non-loopback address on the gateway needs `GatewayPorts yes`\n\nin its `sshd_config`\n\n.\n\n**Pro Tip:** Use `ssh -f -N -R`\n\nto run the SOCKS proxy in the background.\n\n## Lab 6: Using SSH Tunnels for Remote Dynamic Port Forwarding 👨🔬\n\nThis is the home-network scenario from Lab 4 again -\nwe want to expose devices that only `local`\n\ncan reach through the public-facing `remote`\n\ngateway -\nexcept this time a single proxy covers all of them.\n\nFirst, let's make sure we cannot reach the `internal`\n\nhost from the private machine:\n\n```\ncurl 192.168.0.10:80  # internal.home\ncurl: (7) Failed to connect to 192.168.0.10 port 80 after 0 ms: Could not connect to server\n```\n\nNow, from the local host, turn the `remote`\n\ngateway into a SOCKS proxy,\nand establish a tunnel with it:\n\n```\nssh -f -N -R 0.0.0.0:1080 203.0.113.30  # remote.public\n```\n\nTo recheck the connectivity, from the private host again,\nuse the gateway's proxy to reach the `internal`\n\nhome device:\n\n```\ncurl --socks5-hostname 172.16.0.30:1080 192.168.0.10:80\n#                      via              internal.home\nHello from the internal home-network host (192.168.0.10).\n```\n\nThe same proxy reaches anything the `ssh`\n\nclient (`local`\n\n) can - including its own loopback service:\n\n```\ncurl --socks5-hostname 172.16.0.30:1080 127.0.0.1:80\n#                      via              local's localhost\nHello from your local workstation (localhost-only service).\n```\n\n## Summarizing\n\nHere is a quick recap and a couple of mnemonics to help you memorize the SSH tunneling commands:\n\n**Local port forwarding**(`ssh -L`\n\n) makes a remote service available on a local port.**Remote port forwarding**(`ssh -R`\n\n) makes a local service available on a remote port.**Dynamic local port forwarding**(`ssh -D`\n\n) turns the local`ssh`\n\nclient into a SOCKS proxy.**Dynamic remote port forwarding**(`ssh -R`\n\nwith no destination) turns the`sshd`\n\nserver into a SOCKS proxy.- Local port forwarding (\n`ssh -L`\n\n) implies it's the`ssh`\n\nclient that starts listening on a new port. - Remote port forwarding (\n`ssh -R`\n\n) implies it's the`sshd`\n\nserver that starts listening on an extra port. - The word\n**local** can mean either the**SSH client machine** or an internal host accessible from it. - The word\n**remote** can mean either the**SSH server machine (sshd)** or any host accessible from it. - The mnemonics are\n*ssh*and**-L****l** ocal:remote*ssh*and it's always the left-hand side that opens a new port.**-R****r** emote:local\n\nHope the above materials helped you a bit with becoming a master of SSH Tunnels 🧙\n\n## Practice\n\nReinforce your learning by solving these practical challenges:\n\n## Resources\n\n[SSH Tunneling Explained](https://goteleport.com/blog/ssh-tunneling-explained/)by networking gurus from Teleport.[SSH Tunneling: Examples, Command, Server Config](https://www.ssh.com/academy/ssh/tunneling-example)by SSH Academy.\n\n### About the Author\n\nWrites about\n\nFrequently covers", "url": "https://wpnews.pro/news/a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding", "canonical_source": "https://labs.iximiuz.com/tutorials/ssh-tunnels", "published_at": "2026-06-20 03:55:19+00:00", "updated_at": "2026-06-20 04:07:28.058355+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["SSH", "EC2", "MySQL", "Postgres", "Redis", "OpenSearch"], "alternates": {"html": "https://wpnews.pro/news/a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding", "markdown": "https://wpnews.pro/news/a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding.md", "text": "https://wpnews.pro/news/a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding.txt", "jsonld": "https://wpnews.pro/news/a-practical-guide-to-ssh-tunnels-local-and-remote-port-forwarding.jsonld"}}