sshtunnel review
sshtunnel 0.4.0 is a Paramiko wrapper for local SSH port forwarding. `SSHTunnelForwarder` connects to a gateway, binds one or more local TCP ports or Unix sockets, and forwards them to addresses visible from that gateway. It exposes assigned ports to Python code and can start and stop through a context manager. The package does not provide a VPN, SOCKS proxy, database client, or full OpenSSH feature set. Its current release is still the January 2021 build.
sshtunnel 0.4.0 installed in 0.3 seconds and imported in 0.83 seconds in our August 22 sandbox, but its open-ended Paramiko dependency can now resolve to 5.0.0, which removed a key class its source references. Preserve a tested legacy pin if necessary; do not select it for a new 2026 service.
We installed it
| Install | ✓ · 0.3s | 8 packages on disk · 22 MB |
| Import | ✓ | import sshtunnel in 0.83s · pure Python |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does sshtunnel install cleanly?
Yes. In a fresh container with an empty cache, pip install sshtunnel finished in 0.3s, leaving 8 packages and 22 MB on disk. pip-audit reported no known vulnerabilities.
What does sshtunnel need to run?
Python 3.x, and nothing compiled: it is pure Python. In our run import sshtunnel succeeded in 0.83s.
sshtunnel or paramiko: which should you use?
paramiko: Choose it when your application already owns Paramiko and can adapt its maintained forwarding example directly. sshtunnel 0.4.0 installed in 0.3 seconds and imported in 0.83 seconds in our August 22 sandbox, but its open-ended Paramiko dependency can now resolve to 5.0.0, which removed a key class its source references.
When should you not use sshtunnel?
This is a new project. Version 0.4.0 is from January 2021, while AsyncSSH and the system OpenSSH client have current forwarding paths.
Use it if
- An existing pinned deployment already uses `SSHTunnelForwarder` and its Paramiko combination is covered by connection tests.
- A short Python job needs an OS-assigned local port before creating its database or HTTP client.
- A context manager is simpler for this process than supervising an external `ssh -L` command.
- The code will bind only to localhost, verify the gateway host key, and close the forwarder on every path.
- This is a new project. Version 0.4.0 is from January 2021, while AsyncSSH and the system OpenSSH client have current forwarding paths.
- Dependencies cannot be pinned together. sshtunnel declares only `paramiko>=2.7.2`, and its source references `paramiko.DSSKey`, which Paramiko 5.0 no longer exports.
- OpenSSH config behavior must match `ProxyJump`, `Include`, certificates, hardware keys, or system policy. The wrapper does not promise full OpenSSH configuration compatibility.
- A mistaken public bind would be serious. The CLI documentation says that omitting the local host listens on `0.0.0.0`.
- The application is asyncio-native or needs dynamic SOCKS forwarding. This API runs local forwards through threads and does not expose an asyncio transport.
- A security-sensitive network dependency needs frequent compatibility releases. GitHub shows one push in 2025, but PyPI still has no release after 0.4.0.
Setup reality
We installed sshtunnel 0.4.0 in a fresh Python 3.12 Bookworm sandbox on August 22, 2026. pip finished in 0.3 seconds, left 8 packages consuming 22 MB, and found 0 known vulnerabilities. The package has 5 direct dependencies, is pure Python, carries an MIT license, and declares no Python floor. import sshtunnel worked in 0.83 seconds. No py.typed marker was present.
That successful run is a dated resolver result, not proof that every unpinned install will keep working. PyPI now serves Paramiko 5.0.0, while sshtunnel 0.4.0 declares no upper bound and refers to the removed DSSKey class. Lock and test the exact pair used by an existing deployment. A new service should avoid inheriting this compatibility problem.
Configuration needs the gateway address, user, authentication material, and the remote address as resolved from the gateway. Bind ('127.0.0.1', 0) explicitly; port 0 selects an available local port after start(). Supply a trusted ssh_host_key instead of accepting any reachable gateway. Agent and key-directory discovery can choose credentials you did not intend, so disable them where deterministic identity matters.
Create the database or HTTP client inside the tunnel context and close it before the forwarder exits. SSH forwarding adds no database TLS, authentication, pooling, retries, or reconnection. Keepalives can expose a broken path but do not replay interrupted operations. Test shutdown under signals and failed connections because threads and sockets must disappear cleanly.
Patterns
Freeze a legacy dependency pair pin-legacy-environment
# requirements.txt for an existing audited deployment only
sshtunnel==0.4.0
paramiko>=2.7.2,<4Use this only while maintaining an audited deployment. Test the resolved Paramiko patch and schedule replacement work.
Bind a random localhost port forward-local-port
from sshtunnel import SSHTunnelForwarder
with SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
ssh_pkey='/run/secrets/deploy_key',
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
) as tunnel:
connect_database(host='127.0.0.1', port=tunnel.local_bind_port)The assigned `local_bind_port` exists after startup. Explicit localhost binding prevents other hosts from reaching the forwarded service.
Check the gateway identity verify-host-key
with SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
ssh_pkey='/run/secrets/deploy_key',
ssh_host_key='ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI...',
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
) as tunnel:
use(tunnel.local_bind_port)Replace the sample key with one verified out of band. Host-key verification detects a gateway impersonation.
Turn off implicit key selection disable-key-discovery
tunnel = SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
ssh_pkey='/run/secrets/deploy_key',
allow_agent=False,
host_pkey_directories=[],
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
)Agent and home-directory discovery are enabled by default; disabling both makes the selected production key predictable.
Forward two internal services forward-multiple-services
with SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
ssh_pkey='/run/secrets/deploy_key',
remote_bind_addresses=[('db.internal', 5432), ('cache.internal', 6379)],
local_bind_addresses=[('127.0.0.1', 0), ('127.0.0.1', 0)],
) as tunnel:
db_port, cache_port = tunnel.local_bind_portsRemote and local lists correspond by position. Read the assigned ports from the plural property after startup.
Stop a manually managed forwarder start-and-stop-manually
tunnel = SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
remote_bind_address=('service.internal', 8080),
local_bind_address=('127.0.0.1', 0),
)
try:
tunnel.start()
serve_through(tunnel.local_bind_port)
finally:
tunnel.stop()A context manager is safer. If lifecycle is manual, `finally` must close forwarding threads and sockets.
Ask SSH to send keepalives keep-transport-alive
tunnel = SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
set_keepalive=30.0,
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
)Keepalives can reveal a dead transport. They do not reconnect the tunnel or retry the application's failed operation.
Point at one SSH config file use-ssh-config
tunnel = SSHTunnelForwarder(
'bastion-alias',
ssh_config_file='/etc/myapp/ssh_config',
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
)Do not assume complete OpenSSH parsing. Reports around `Include` and `ProxyJump` require a test with the exact configuration.
Run a localhost-only CLI forward use-cli-forward
python -m sshtunnel \
-U deploy \
-K /run/secrets/deploy_key \
-L 127.0.0.1:15432 \
-R db.internal:5432 \
bastion.example.comInclude `127.0.0.1` in `-L`; leaving out the host makes the documented default listen on all interfaces.
Capture tunnel diagnostics enable-debug-logging
import logging
from sshtunnel import create_logger, SSHTunnelForwarder
logger = create_logger(loglevel=logging.DEBUG)
tunnel = SSHTunnelForwarder(
('bastion.example.com', 22),
ssh_username='deploy',
remote_bind_address=('db.internal', 5432),
local_bind_address=('127.0.0.1', 0),
logger=logger,
)Debug output can expose users, internal hosts, ports, and key paths. Limit access and return logging to normal afterward.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| paramiko | PyPI | Choose it when your application already owns Paramiko and can adapt its maintained forwarding example directly. |
| asyncssh | PyPI | Choose it for asyncio-native SSH with maintained local and remote forwarding APIs. |
| fabric | PyPI | Choose it when tunnels are part of a larger Python SSH command and deployment workflow. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

