<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Yegor's Dev Notes]]></title><description><![CDATA[Practical notes on SSH, Linux, servers, networking, developer tools, and problems I run into while building software.]]></description><link>https://yegorkaliuzhnyi.hashnode.dev</link><image><url>https://cdn.hashnode.com/res/hashnode/image/upload/v1593680282896/kNC7E8IR4.png</url><title>Yegor&apos;s Dev Notes</title><link>https://yegorkaliuzhnyi.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Wed, 07 Oct 2026 12:04:05 GMT</lastBuildDate><atom:link href="https://yegorkaliuzhnyi.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[SSH Port Forwarding Without the Confusing Terminology]]></title><description><![CDATA[SSH port forwarding is one of those features that looks harder than it really is.
The syntax for local, remote, and dynamic forwarding is similar enough that it's easy to mix them up. But you don't ne]]></description><link>https://yegorkaliuzhnyi.hashnode.dev/ssh-port-forwarding-without-the-confusing-terminology</link><guid isPermaLink="true">https://yegorkaliuzhnyi.hashnode.dev/ssh-port-forwarding-without-the-confusing-terminology</guid><category><![CDATA[ssh]]></category><category><![CDATA[Linux]]></category><category><![CDATA[Devops]]></category><category><![CDATA[networking]]></category><dc:creator><![CDATA[Yegor Kaliuzhnyi]]></dc:creator><pubDate>Thu, 01 Oct 2026 00:13:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6abda3becbdb7bc75dcc759d/062307e4-ed73-458a-97de-b914c8ac5062.webp" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>SSH port forwarding is one of those features that looks harder than it really is.</p>
<p>The syntax for local, remote, and dynamic forwarding is similar enough that it's easy to mix them up. But you don't need to memorize three unrelated commands. The useful question is simpler:</p>
<p><strong>Where does the connection begin, and where should the traffic end up?</strong></p>
<p>Once that direction is clear, <code>-L</code>, <code>-R</code>, and <code>-D</code> become much easier to reason about.</p>
<h2>What an SSH tunnel actually does</h2>
<p>A normal SSH connection gives you an encrypted, authenticated connection to another machine:</p>
<pre><code class="language-bash">ssh user@server
</code></pre>
<p>SSH can carry more than an interactive shell over that connection. Port forwarding uses the same encrypted channel to carry traffic for another service.</p>
<p>That can be useful when a database, internal dashboard, Redis instance, or development service should not be exposed directly to the public internet. Your application talks to a port, while SSH moves that traffic through the encrypted connection to its actual destination.</p>
<p>This is different from router port forwarding. Router forwarding changes NAT or firewall rules for inbound traffic. SSH forwarding happens inside an existing SSH connection.</p>
<h2>Local forwarding: <code>-L</code></h2>
<p>Use local forwarding when <strong>you are on your machine and need to reach something available from the remote side</strong>.</p>
<p>The general form is:</p>
<pre><code class="language-bash">ssh -L local_port:destination_host:destination_port user@ssh_server
</code></pre>
<p>For example, imagine MySQL is running on a remote server on port <code>3306</code>, but the database only listens locally and isn't exposed to the internet.</p>
<p>You can create this tunnel:</p>
<pre><code class="language-bash">ssh -L 3306:localhost:3306 deploy@db-host.example.com
</code></pre>
<p>Then point your local database client at:</p>
<pre><code class="language-text">localhost:3306
</code></pre>
<p>Your client behaves as if MySQL were running locally. In reality, traffic goes through SSH to <code>db-host.example.com</code>, where it reaches port <code>3306</code>.</p>
<p>The important part is that MySQL itself never needs a public port.</p>
<p>The same pattern works for Redis, internal admin panels, monitoring dashboards, and other services reachable from the SSH server.</p>
<p>A useful shortcut is:</p>
<blockquote>
<p><code>-L</code> means I need a <strong>local</strong> port that reaches something on the remote side.</p>
</blockquote>
<p>If you run into a loopback mismatch, explicitly using <code>127.0.0.1</code> instead of <code>localhost</code> can also help on systems where <code>localhost</code> resolves to IPv6 first while the service or forward is listening only on IPv4.</p>
<h2>Remote forwarding: <code>-R</code></h2>
<p>Remote forwarding reverses the direction.</p>
<p>Use it when <strong>something on the remote side needs a path back to a service on your machine</strong>.</p>
<p>The syntax is:</p>
<pre><code class="language-bash">ssh -R remote_port:local_host:local_port user@ssh_server
</code></pre>
<p>Suppose you're running a development server locally:</p>
<pre><code class="language-text">localhost:3000
</code></pre>
<p>You can make it reachable from the SSH server through port <code>8080</code>:</p>
<pre><code class="language-bash">ssh -R 8080:localhost:3000 user@ssh_server
</code></pre>
<p>Connections to that forwarded port travel backward through the SSH connection and reach your local service on port <code>3000</code>.</p>
<p>This is why <code>-R</code> is commonly used for reverse SSH tunnels. It's useful when the machine hosting a service has no convenient inbound route — for example, when it's behind NAT or a firewall — but it can still establish an outbound SSH connection.</p>
<p>By default, a remote-forwarded port is typically bound to loopback on the SSH server. Making it reachable beyond the server itself can involve SSH server settings such as <code>GatewayPorts</code>, along with firewall and network configuration.</p>
<p>So <code>-R</code> does <strong>not</strong> automatically mean "make my local service public."</p>
<p>The mental model is:</p>
<blockquote>
<p><code>-R</code> means the <strong>remote</strong> side gets a port that leads back to me.</p>
</blockquote>
<h2>Dynamic forwarding: <code>-D</code></h2>
<p>Dynamic forwarding solves a different problem.</p>
<p>With <code>-L</code> and <code>-R</code>, you choose a fixed destination in advance. With <code>-D</code>, SSH creates a SOCKS proxy:</p>
<pre><code class="language-bash">ssh -D 1080 user@ssh_server
</code></pre>
<p>That gives you a SOCKS proxy on:</p>
<pre><code class="language-text">localhost:1080
</code></pre>
<p>An application that supports SOCKS can then choose its own destinations while sending traffic through the SSH server.</p>
<p>For example:</p>
<pre><code class="language-bash">curl -x socks5h://localhost:1080 https://example.com
</code></pre>
<p>This makes <code>-D</code> useful when you want selected application traffic to travel through a trusted SSH host without creating a separate tunnel for every destination.</p>
<p>A simple way to remember it:</p>
<blockquote>
<p><code>-D</code> = <strong>dynamic destination</strong>.</p>
</blockquote>
<h2>The three flags in one view</h2>
<p>The commands may look similar:</p>
<pre><code class="language-bash"># Local forwarding
ssh -L 3306:localhost:3306 user@server

# Remote forwarding
ssh -R 8080:localhost:3000 user@server

# Dynamic forwarding
ssh -D 1080 user@server
</code></pre>
<p>But they answer different questions:</p>
<ul>
<li><p><code>-L</code> — I need to reach something through the SSH server.</p>
</li>
<li><p><code>-R</code> — the SSH server side needs a path back to something on my side.</p>
</li>
<li><p><code>-D</code> — my application should choose destinations dynamically through a SOCKS proxy.</p>
</li>
</ul>
<p>That's the part worth remembering. The syntax is much easier once the direction makes sense.</p>
<h2>Run a tunnel without opening a shell</h2>
<p>If the tunnel is the only reason you're connecting, you don't need an interactive remote shell.</p>
<p>Use <code>-N</code>:</p>
<pre><code class="language-bash">ssh -N -L 3306:localhost:3306 deploy@db-host.example.com
</code></pre>
<p><code>-N</code> tells SSH not to execute a remote command.</p>
<p>You can also use <code>-f</code> to background the SSH process after authentication:</p>
<pre><code class="language-bash">ssh -f -N -L 3306:localhost:3306 deploy@db-host.example.com
</code></pre>
<p>For connections that may stay idle, SSH keepalives can help detect a dead connection:</p>
<pre><code class="language-bash">ssh -f -N -L 3306:localhost:3306 \
  -o ServerAliveInterval=30 \
  -o ServerAliveCountMax=3 \
  deploy@db-host.example.com
</code></pre>
<p>One distinction matters: keepalives help SSH notice that a connection has died. They do not automatically reconnect the tunnel after SSH exits.</p>
<h2>Put repeated tunnels in SSH config</h2>
<p>If you use the same tunnel regularly, moving it into <code>~/.ssh/config</code> is cleaner than keeping a long command around.</p>
<pre><code class="language-sshconfig">Host db-tunnel
  HostName db-host.example.com
  User deploy
  LocalForward 3306 localhost:3306
  ServerAliveInterval 30
  ServerAliveCountMax 3
</code></pre>
<p>Now the tunnel can be opened with:</p>
<pre><code class="language-bash">ssh db-tunnel
</code></pre>
<p>That is easier to maintain and much harder to mistype.</p>
<h2>Pay attention to what the forwarded port exposes</h2>
<p>SSH encrypts the tunnel, but that doesn't automatically make every forwarding configuration safe.</p>
<p>The bind address matters.</p>
<p>A forwarded port bound to:</p>
<pre><code class="language-text">127.0.0.1
</code></pre>
<p>is available only from that machine.</p>
<p>A port bound to:</p>
<pre><code class="language-text">0.0.0.0
</code></pre>
<p>can potentially be reached from other machines, depending on firewall and network rules.</p>
<p>That distinction is especially important with remote forwarding. Changing settings such as <code>GatewayPorts</code> can make a forwarded service reachable beyond the SSH server itself.</p>
<p>Sometimes that's exactly what you need. Other times it turns a private database or development service into something exposed much more broadly than intended.</p>
<p>Also close tunnels you no longer need. A forgotten tunnel is still an access path to the service behind it.</p>
<h2>The practical rule</h2>
<p>Don't start by asking, "Was remote forwarding <code>-L</code> or <code>-R</code>?"</p>
<p>Start with the traffic direction.</p>
<p>If you're sitting on your machine and need access to something reachable from the server, use <code>-L</code>.</p>
<p>If the remote side needs a path back to something running on your machine, use <code>-R</code>.</p>
<p>If an application needs to choose multiple destinations through the SSH host, use <code>-D</code>.</p>
<p>Once you think about SSH forwarding that way, the flags stop feeling arbitrary.</p>
<p>I keep a more reference-oriented version with additional examples, configuration notes, and security details in my <a href="https://sshflow.com/blog/ssh-tunneling/">SSH port forwarding guide on SSHFlow</a></p>
]]></content:encoded></item></channel></rss>