Skip to content

Deploy through a jump host

A deployment target does not need a public SSH port. Name the bastion it is reached through, and Onebox tunnels the connection through it:

environments:
production:

jump sits beside server and takes the same two forms it does — the one-line user@host above, or an object when you want to be explicit:

environments:
production:
jump:
host: bastion.example.com
user: deploy
port: 2222

The user and port are optional in both forms: port 22 is used when you name none, and $USER supplies the user — ob does not read ~/.ssh/config, so a User or ProxyJump written there has no effect on it. An environment with no jump connects directly, exactly as before.

An IPv6 bastion is bracketed in the one-line form, where the brackets are what separate the address from the port, and bare in the object form, where host and port are already separate fields. Brackets carried over into host are stripped rather than refused:

jump: "deploy@[2001:db8::1]:2222"
# or
jump: { host: "2001:db8::1", user: deploy, port: 2222 }

Nothing is deployed to the jump host. It forwards one TCP connection to the server and runs no commands, holds no releases, and needs no Docker. Onebox remains one application on one host; the bastion is only how that host is reached.

Both hops are verified and authenticated separately:

  1. The bastion’s host key is checked against known_hosts.
  2. Onebox authenticates to the bastion.
  3. The bastion opens a forwarded connection to the server.
  4. The server’s host key is checked against known_hosts, independently.
  5. Onebox authenticates to the server.

A trusted bastion does not vouch for the server. Trusting one is not trusting the other, and neither key is ever accepted implicitly.

The SSH agent is never forwarded. Your local agent may sign for either hop, but its socket is not exposed to the bastion, so a compromised jump host cannot borrow your identity to reach anything else.

Exactly one hop is supported. There is no ProxyCommand, and Onebox does not read ~/.ssh/config — a ProxyJump there has no effect on ob, because the transport dials and verifies on its own rather than shelling out to ssh.

Because each hop is verified, known_hosts needs an entry for each. The bastion is reachable from your machine:

Terminal window
ssh-keyscan -H bastion.example.com >> ~/.ssh/known_hosts

The server is not, so scan it from the bastion and append the result locally:

Terminal window
ssh [email protected] 'ssh-keyscan -H 10.20.0.10' >> ~/.ssh/known_hosts

Read that second key the way you would any key you did not fetch yourself: it arrives over a connection the bastion mediates, so it is only as trustworthy as the bastion at the moment you enrolled it. Comparing it against the key printed on the server’s own console is the stronger move where you can.

If the server listens on a non-default port, known_hosts must be keyed with it — ssh-keyscan -p 2222 writes the [host]:port form Onebox looks up.

Plans and approvals name the whole route, not just the server:

Changing the bastion changes what you approved, so an approval issued for one route does not execute against another. Confirm the route on that line before approving, exactly as you would the server.

Errors name the hop and the stage, so a failure points at one thing to fix:

Error beginsWhat failed
jump ssh …: (no stage)The bastion could not be resolved, connected to, or finished a handshake with — including a timeout
jump ssh …: host key:The bastion’s key is missing from, or disagrees with, known_hosts
jump ssh …: authenticate:The bastion refused your key
target ssh …: not reachable from the jump host:The bastion connected but could not reach the server — wrong private address, or the bastion’s own policy forbids the forward
target ssh …: host key:The server’s key is missing or mismatched, even though the bastion is trusted
target ssh …: authenticate:The server refused your key

A failure that names no stage is a transport problem — a timeout, a cancelled command, or a peer that hung up — never a rejected key.

The distinction between the last four is the one worth internalising: a working bastion tells you nothing about the server, and Onebox will not let a trusted first hop paper over a problem with the second.

A hook declared local: true runs on your machine, which has no tunnel of its own. Such a hook receives ONEBOX_SSH_JUMP alongside ONEBOX_SERVER, empty when the connection is direct:

Terminal window
ssh ${ONEBOX_SSH_JUMP:+-J "$ONEBOX_SSH_JUMP"} "$ONEBOX_SERVER" -p "$ONEBOX_SSH_PORT" 'uptime'

A local hook written before you added a bastion will otherwise try to reach a server it can no longer see. The rest of the namespace is listed under Environment variables.