# sshtunnel is SSH port forwarding you can embed in a Python program

> sshtunnel is a MIT licensed pure Python library that builds SSH tunnels on top of paramiko, offering a forwarder object you can start and stop, a context manager that does both, and a command line interface that mirrors the -L and -R options of the ssh binary. The whole implementation is a single module, and the scenarios it targets are a service behind a firewall and a private host reachable only from a gateway.

**pahaz/sshtunnel** — SSH tunnels to remote server.

- Repository: https://github.com/pahaz/sshtunnel
- Stars: 1,305 · Forks: 201
- Language: Python
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/pahaz-sshtunnel

## A tunnel as an object rather than a child process

sshtunnel is described as pure python SSH tunnel utils, and the requirement list is one line: paramiko.

That single dependency is the design. The library does not implement SSH, it implements the forwarding on top of paramiko's SSH client, which means the tunnel is an object in your process rather than an ssh subprocess you have to spawn, monitor and clean up. For a long running service that needs to open a tunnel to a database for the duration of a job, that difference is the whole point.

The inspiration note in the README is about the same thing. It is inspired by bgtunnel, which does not work on Windows, and it points at the forwarding demo in paramiko's own repository. So the project exists because the existing options either were not portable or were not embeddable, and the fix was to write the forwarding logic directly on top of paramiko.

The scenarios are drawn as two diagrams in the README, and they are worth reading because they define what the library is for.

In the first, a local client opens an SSH connection to a remote server whose only reachable port is the SSH port at 22, and the web service the client actually wants is on port 8080 behind a firewall that opens only port 22. The tunnel carries that port across.

In the second, the remote server itself is the only thing reachable, and a private server behind it is reachable from the remote server's perspective but not from the outside. The tunnel reaches through to the private server, with the firewall opening only port 443 for SSH.

Both are the everyday reason people reach for SSH forwarding, and the second one is the one that trips up people who assume a tunnel only exposes a remote port to a local port.

## Two APIs, one of them handles the teardown for you

The library offers either initialising the tunnel and starting it, or using a with context, which takes care of starting and stopping the tunnel.

The explicit form is a forwarder object with a start and a stop, and the first example in the README shows it with password authentication and a randomly assigned local bind port:

```python
from sshtunnel import SSHTunnelForwarder

server = SSHTunnelForwarder(
    'alfa.8iq.dev',
    ssh_username="pahaz",
    ssh_password="secret",
    remote_bind_address=('127.0.0.1', 8080)
)

server.start()

print(server.local_bind_port)  # show assigned local port
# work with `SECRET SERVICE` through `server.local_bind_port`.

server.stop()
```

The local_bind_port attribute is the important detail. When no local port is specified, one is assigned, and the caller has to read it back to know where to connect, which is the pattern any application driving a tunnel needs.

The context manager form is the one to reach for by default, because the failure mode of the explicit form is a tunnel left open when an exception escapes, and the context manager removes that class of bug entirely. The third example uses it against a Vagrant MySQL port:

```python
from sshtunnel import open_tunnel
from time import sleep

with open_tunnel(
    ('localhost', 2222),
    ssh_username="vagrant",
    ssh_password="vagrant",
    remote_bind_address=('127.0.0.1', 3306)
) as server:

    print(server.local_bind_port)
    while True:
        # press Ctrl-C for stopping
        sleep(1)
```

The open_tunnel function is also where the private server scenario is expressed, with a local_bind_address that is not the default, and a key file with a passphrase:

```python
with sshtunnel.open_tunnel(
    (REMOTE_SERVER_IP, 443),
    ssh_username="",
    ssh_pkey="/var/ssh/rsa_key",
    ssh_private_key_password="secret",
    remote_bind_address=(PRIVATE_SERVER_IP, 22),
    local_bind_address=('0.0.0.0', 10022)
) as tunnel:
```

That example also shows the intended composition. The tunnel is opened, and then a paramiko SSHClient connects to it on the local port, so the tunnel is used as the transport for a further SSH session rather than for an application protocol.

## Chaining tunnels works, with one documented caveat

The fourth example opens an SSH session that jumps over two tunnels, which is the case of a target that is only reachable from a second gateway that is itself only reachable from the first.

The structure is nested contexts, one open_tunnel inside another, with the second tunnel's ssh_address set to the local port of the first, and then a paramiko SSHClient connecting through the second:

```python
with sshtunnel.open_tunnel(
    ssh_address_or_host=('GW1_ip', 20022),
    remote_bind_address=('GW2_ip', 22),
) as tunnel1:
    with sshtunnel.open_tunnel(
        ssh_address_or_host=('localhost', tunnel1.local_bind_port),
        remote_bind_address=('target_ip', 22),
        ssh_username='GW2_user',
        ssh_password='GW2_pwd',
    ) as tunnel2:
        with SSHClient() as ssh:
            ssh.connect('localhost',
                port=tunnel2.local_bind_port,
                username='target_user',
                password='target_pwd',
            )
            ssh.exec_command(...)
```

The caveat is stated in the README and is the sort of thing that causes a hang. The SSH transport and tunnels will be daemonised, which will not wait for the connections to stop at close time. In other words, closing the context manager tears down the Python objects, but the underlying transport and forwarding threads are detached, and the connections they are carrying can outlive the with block.

That is a deliberate design, and it is not a small one. It is what lets a tunnel survive the code that opened it, which is exactly what you want when a long lived service opens a tunnel once and closes it much later. It is also what makes an interactive session awkward, because pressing Ctrl-C to stop the third example leaves something behind.

So the library is optimised for the embedded, long lived case, and the price is that the teardown is not as complete as a Python purist would expect.

## The CLI mirrors the ssh flags you already know

There is a command line interface as well, and its shape is deliberately familiar, since the whole point is that the ssh binary does the same job.

The console example in the README forwards a Vagrant MySQL port and reads as a near copy of an ssh command line:

```console
(bash)$ python -m sshtunnel -U vagrant -P vagrant -L :3306 -R 127.0.0.1:3306 -p 2222 localhost
```

The help output confirms the mapping. The usage line is sshtunnel with -h for help, -U SSH_USERNAME for the account, -p SSH_PORT for the server port with a default of 22, -P SSH_PASSWORD, -R IP:PORT for remote bind addresses and -L for local ones, -k SSH_HOST_KEY, -K KEY_FILE, -S KEY_PASSWORD for the key passphrase, plus -t, -v, -V, -x IP:PORT, -c SSH_CONFIG_FILE, -z, -n and -d [FOLDER ...]. The positional argument is the ssh_address, the SSH server IP address, described as the gateway for SSH tunnels.

The flags that go beyond ssh are the ones a Python implementation can offer. -x takes an IP:PORT, -c points at an SSH config file, -z and -n are switches whose meaning the help text does not spell out in the visible portion, and -d takes a folder.

For a shell user this CLI is not a reason to prefer the library over the ssh binary. It is a convenience, and the honest case for sshtunnel is the API, where the tunnel is an object with a lifecycle, the local port is a value you can read, and the whole thing composes with other paramiko sessions in the same process.

## Install from PyPI, conda or source, and run the tests

The package is on PyPI, and the README lists three ways to install it.

The shortest is pip:

```
pip install sshtunnel
```

There is also easy_install sshtunnel, and a conda install from the conda-forge channel:

```
conda install -c conda-forge sshtunnel
```

For a source install, the README says to clone the repo and run python setup.py install.

Testing the package needs tox, and the command is python setup.py test.

The repository layout is small and reveals how little there is to it. The implementation is a single file, sshtunnel.py, and the setup script even reads the version out of that file with a regular expression against the __version__ assignment, which is a trick that avoids duplicating the version in two places. Around it are setup.py, setup.cfg, pyproject.toml, a MANIFEST.in, a tox.ini, a tests/ directory, an e2e_tests/ directory, a docs/ directory, and three RST documents at the top level, docs.rst, changelog.rst and Troubleshoot.rst, the last of which is a troubleshooting guide rather than a manual.

The build metadata is mostly legacy. pyproject.toml declares setuptools and wheel with the legacy build backend, and the pytest configuration filters a cryptography deprecation warning, which tells you that the library still runs against a cryptography version that warns. The continuous integration configuration is split between CircleCI at .circleci/ and AppVeyor at appveyor.yml, which is the combination you would expect from a project that started on one and added the other for Windows coverage.

## A long gap between releases, and what the version numbers say

The release history is worth understanding before you pin a version. The tags are 0.5.1 from 2026-10-09, 0.4.0 from 2021-01-11 and 0.3.2 from the same day in January 2021.

So there is a five year gap between 0.4.0 and 0.5.1, and a patch release in between two minor ones that were published on the same day. The repository is not archived and the last push was on 2026-10-15, six days after the 0.5.1 tag, so the code is being worked on and the version line is moving again.

What that pattern suggests is a project that was stable for a long time and has recently had work done on it, rather than one that ships continuously. For a library this small, that is not a bad record. The implementation is a single module with one dependency, and the surface area that can break is small.

The licence is MIT, as declared in the setup script's licence field and the LICENSE file at the root of the repository.

For an adopter the practical questions are narrow. Does the version you take support the authentication your deployment uses, in particular key files with a passphrase and the ssh config file, since the examples cover a password, a key file with a passphrase and a private key path? And is the daemonic teardown behaviour acceptable for how you use it, given that closing the context manager does not wait for the connections to stop?

If you are reaching for this in a shell script rather than in Python, the ssh binary does the same job with better key handling, a config file and a ProxyJump option, and the difference in approach is that sshtunnel gives you a programmatic handle on the tunnel rather than a child process to manage.

## Conclusion

sshtunnel suits a Python application that needs to reach a service across an SSH connection, and that would rather hold the tunnel as an object in its own process lifecycle than shell out to the ssh binary and parse its output. It does not suit a shell user, who already has ssh with local and remote forwarding, key handling and a config file, and it is the wrong tool if you need a tunnel that outlives your program. Verify first which authentication your deployment actually uses, since the examples in the README pass passwords on the command line, and check the version you pin, because the release history has a long gap between 0.4.0 in January 2021 and 0.5.1 in October 2026.

## FAQ

### What is sshtunnel and what does it need to work?

It is a pure Python library for SSH tunnels, and the README lists one requirement, paramiko. The implementation is a single module, sshtunnel.py, and it is MIT licensed and available on PyPI and on the conda-forge channel.

### How do I install sshtunnel?

With pip install sshtunnel, with easy_install sshtunnel, with conda install -c conda-forge sshtunnel, or from a clone of the repository followed by python setup.py install. Running the tests needs tox and the command python setup.py test.

### What is the difference between SSHTunnelForwarder and open_tunnel?

The API allows either initialising the tunnel and starting it yourself, with start and stop on a forwarder object, or using the with context, which takes care of starting and stopping the tunnel for you.

### How do I find the local port the tunnel was assigned?

Through the local_bind_port attribute of the forwarder or tunnel object, which the examples print after starting, since a local port is assigned when none is specified.

### Does closing the tunnel context manager stop the connections immediately?

Not exactly. The README notes that the SSH transport and tunnels will be daemonised, which will not wait for the connections to stop at close time, so the underlying transport can outlive the with block.

## Sources

- [Issues](https://github.com/pahaz/sshtunnel/issues)
- [License: MIT](https://github.com/pahaz/sshtunnel/blob/master/LICENSE)
- [pahaz/sshtunnel on GitHub](https://github.com/pahaz/sshtunnel)
- [README](https://github.com/pahaz/sshtunnel/blob/master/README.md)
- [Releases](https://github.com/pahaz/sshtunnel/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/pahaz-sshtunnel
