ShellYard

May 19, 2026 · ssh · api · networking · bastion · devops

How to Test an Internal API Behind a Bastion (No Port Forwarding)

Need to hit an API that only lives behind a jump host? Here's how to reach it with SSH port forwarding, a SOCKS proxy, or by routing the request through your SSH session.

By ShellYard

You have an API running somewhere inside a private network — on a VLAN your laptop can't route to, or on a host that only resolves from inside the environment. You can SSH to the bastion in front of it, but you can't reach the API directly. And right now you just want to fire an HTTP request at it and see what comes back.

From your machine, the obvious thing fails:

curl http://inner-api:8080/health
# curl: (6) Could not resolve host: inner-api

Your laptop has no DNS entry for inner-api and no network route to it. Only the bastion does. Here are the ways to bridge that gap — the standard manual approaches, the friction each one carries, and a way to skip the tunnel-wrangling entirely.

Why you can't hit it directly

A bastion (or jump host) exists precisely so that internal services aren't reachable from the outside. The API lives on a private subnet; the only machine you can reach that can also reach the API is the bastion. So any request has to originate from — or pass through — the bastion's network context, where inner-api resolves and the port is open.

That leaves you three practical options.

Option 1: SSH local port forwarding (ssh -L)

The classic move. Open a tunnel that maps a local port on your laptop to the internal service, routed through the bastion:

ssh -L 8080:inner-api:8080 user@bastion

This says: listen on localhost:8080 on my machine, and forward anything that hits it through the bastion to inner-api:8080 — where inner-api is resolved from the bastion's side. With that session open, point your HTTP client at the local end:

curl http://localhost:8080/health

It works. But it comes with baggage:

  • Port bookkeeping. You have to remember that localhost:8080 means inner-api, localhost:8081 means something else, and so on. Map two services to the same local port and you get a collision.
  • The hostname is now localhost. This is the big one. Your request is going to localhost, not inner-api, so anything that depends on the real host can break: virtual-host routing on the server, host-scoped cookies, and absolute-URL redirects that bounce you back to a hostname your laptop can't resolve.
  • TLS gets awkward. If the API speaks HTTPS, the certificate is issued for inner-api, but you're connecting to localhost — so validation fails unless you start passing flags to override the host or skip verification, which is its own footgun.
  • You're babysitting a process. The tunnel only lives as long as that SSH session stays open in a terminal somewhere.
  • Multi-hop needs more. If the API is two hops in (laptop → bastion → app host), you're reaching for ProxyJump:
ssh -J user@bastion -L 8080:inner-api:8080 user@app-host

For a one-off check when you already live in the terminal, ssh -L is fine. It gets old fast when you're doing it repeatedly or across several services.

Option 2: A dynamic SOCKS proxy (ssh -D)

If you need to reach several internal hosts rather than one specific port, a dynamic proxy is cleaner than a pile of -L mappings. Open a SOCKS proxy through the bastion:

ssh -D 1080 user@bastion

Then tell your HTTP client to route through it. With curl:

curl --proxy socks5h://localhost:1080 http://inner-api:8080/health

The detail that matters is the h in socks5h — it resolves DNS through the proxy, on the bastion's side. That means internal hostnames like inner-api resolve correctly, and — crucially — you keep the real hostname instead of rewriting everything to localhost. That alone fixes most of the Host-header and TLS pain from Option 1, and one proxy reaches any internal host instead of one tunnel per service.

The trade-offs: your HTTP client has to support SOCKS proxies (most do, but you'll be setting proxy config per tool), and you're still standing up and keeping alive an out-of-band proxy process.

Option 3: Route the request through the SSH session itself

Both manual options share the same shape: you set up a tunnel or proxy outside your HTTP client, then point the client at it and manage the plumbing separately. The setup step and the bookkeeping are the friction — not the request itself.

The way I handle this now is to skip the separate tunnel entirely and route the request through an SSH session directly. In ShellYard, the SSH connections and the HTTP client live in the same app, so you can open a connection to the bastion, open your request, and pick that session as the request's route. The request then resolves and connects from the remote host's context — inner-api resolves, the real Host is preserved, and there's no local port to map and no proxy to configure per tool.

In practice it's three steps: open the SSH connection, open the request, choose the session as the route, send. No -L mapping to remember, no localhost rewriting, no proxy process to babysit in another terminal.

Which one should you use?

  • One-off check, you're already in a shell: ssh -L. It's built in and it's fast for a single service.
  • Reaching many internal hosts at once: ssh -D with a socks5h proxy, so DNS resolves remotely and you keep real hostnames.
  • You test internal APIs regularly and don't want the tunnel bookkeeping: route the request through the SSH session you already have, so the plumbing disappears into the request itself.

All three get you to the same place — a request that originates from inside the private network. The manual methods just ask you to stand up and maintain the tunnel yourself, with a few footguns (localhost rewriting, TLS mismatches, keeping processes alive) along the way.

If you hit internal APIs behind a bastion often enough that the setup step has become a tax, ShellYard removes it — it's free, runs locally, and doesn't require an account. Download it here and point a request at something behind your jump host.