Skip to content
Go back

SSH Bastion & Jump Host Patterns That Don't Hurt

By SumGuy 9 min read
SSH Bastion & Jump Host Patterns That Don't Hurt

Your Port 22 Is Showing

Here’s a thing that happens. You spin up a VPS. You need to SSH in. You open port 22 to 0.0.0.0/0 because it’s faster. You tell yourself you’ll lock it down later.

Later never comes.

Six months go by and your auth logs look like a brute-force greatest-hits album. [email protected], [email protected], 400 attempts a minute, every minute, forever. Congratulations. You’ve made fail2ban work for a living.

The fix isn’t complicated: one hardened host with port 22 exposed, everything else locked down behind it. That’s a bastion. And no — it’s not some enterprise relic from 2008. It’s how you run a home lab or a small fleet without losing sleep.


What a Bastion Actually Is

A bastion host (also called a jump host) is a single server that lives in your DMZ or has a public IP. It’s the only box with SSH exposed to the internet. Every other internal host: no public port, no direct access, nothing. You SSH into the bastion, then through it to reach internal hosts.

The key insight is that you’ve collapsed your attack surface from N servers down to one. Now all your hardening effort, your logging, your MFA, your certificate auth — it all goes on one box. That’s a trade worth making.


ProxyJump: The Right Way Since OpenSSH 7.3

If you’re on OpenSSH 7.3+ (anything released after 2016 — you’re fine), ProxyJump is the clean way to chain through a bastion.

Terminal window
# Single hop: jump through bastion to reach internal host
# Multi-hop: chain through two jumps

That’s it. SSH handles the tunnel transparently. You get a direct interactive session on the target — no manual ssh from the bastion, no screen sessions, no nonsense.

Making It Permanent with ssh_config

Typing -J bastion.example.com every time is fine once. It’s annoying the fiftieth time. Put this in ~/.ssh/config:

~/.ssh/config
# The bastion itself
Host bastion
HostName bastion.example.com
User deploy
IdentityFile ~/.ssh/id_ed25519_bastion
Port 22
# All internal hosts — jump through bastion automatically
Host 192.168.10.*
ProxyJump bastion
User deploy
IdentityFile ~/.ssh/id_ed25519_internal
# Or match by alias
Host app-server
HostName 192.168.10.50
ProxyJump bastion
User deploy

Now ssh app-server just works. No flags, no thinking, no 2 AM typos.

ProxyCommand for Older OpenSSH

Stuck on an older client or need NetCat-style control? ProxyCommand is the pre-7.3 approach:

~/.ssh/config (legacy)
Host app-server-legacy
HostName 192.168.10.50
User deploy
ProxyCommand ssh -W %h:%p [email protected]

-W tells the bastion SSH to forward stdin/stdout to %h:%p (the target host and port). Same result, more typing. Stick with ProxyJump unless you have a real reason not to.


Hardening the Bastion

A bastion with sloppy config is just a single-point-of-failure with extra steps. These are the non-negotiables.

sshd_config Lockdown

/etc/ssh/sshd_config
# No password auth — keys only
PasswordAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
# Limit to your ops users
AllowGroups sshusers
# Kill idle sessions
ClientAliveInterval 300
ClientAliveCountMax 2
# No X11, no agent forwarding on the bastion itself
X11Forwarding no
AllowAgentForwarding no
# Force a specific key exchange for compliance nerds
KexAlgorithms curve25519-sha256,ecdh-sha2-nistp521

AllowAgentForwarding no is worth emphasizing. Agent forwarding on the bastion means an attacker who compromises the bastion can hijack your SSH agent and impersonate you to internal hosts. Kill it.

SSH Certificate Authority

For anything beyond two or three servers, managing authorized_keys files is miserable. You add a new key, you forget to push it to three servers, you get locked out at 11 PM on a Friday.

Short-lived SSH certificates from a CA solve this properly. I’ve covered the full setup in the SSH Certificate Authority article — go read that. The short version: your CA signs user certificates with a TTL, bastion validates them, no authorized_keys management, certificates expire automatically.

For the bastion specifically:

Terminal window
# Sign a user cert valid for 8 hours, only valid for the bastion host
ssh-keygen -s /etc/ssh/ca_user_key -I "deploy@$(hostname)" \
-n deploy -V +8h -O source-address=203.0.113.0/24 \
~/.ssh/id_ed25519.pub

The -O source-address constraint locks the cert to your home IP or office CIDR. Even if someone steals the cert, it only works from your IP range.

2FA on the Bastion

Certificates are great. Certificates plus TOTP is better. PAM-based TOTP (Google Authenticator PAM module) adds a time-based OTP prompt on top of your key auth. Full walkthrough in 2FA for SSH with PAM TOTP — don’t skip it for your bastion.


Session Recording

This is the part most home lab guides skip and enterprises pay six figures for.

Recording what happens on your bastion isn’t about surveillance — it’s about auditability. When something goes wrong (and something always goes wrong), you want a replay of exactly what commands ran. Regulatory frameworks require it. Common sense appreciates it.

Option 1: auditd (Kernel-Level)

auditd records every syscall. For SSH sessions, you care about execve (commands executed) and file writes:

Terminal window
# Install
apt install auditd audispd-plugins
# Watch exec calls from sshd children
auditctl -a always,exit -F arch=b64 -S execve -F uid>=1000 -k ssh_exec
# Check logs
ausearch -k ssh_exec --start today | aureport -f -i

The output is thorough and ugly. aureport makes it less ugly. It’s kernel-level, so you can’t escape it from userspace — that’s the point.

Option 2: Asciinema (Human-Readable Replays)

auditd gives you syscalls. Asciinema gives you a full terminal replay you can actually watch:

Terminal window
# Install
pip install asciinema
# Record a session manually
asciinema rec /var/log/sessions/$(whoami)-$(date +%Y%m%d-%H%M%S).cast
# Auto-record on login via /etc/profile.d/
# Add to /etc/profile.d/record-session.sh:
# if [ -n "$SSH_CONNECTION" ]; then
# asciinema rec /var/log/sessions/$(id -un)-$(date +%s).cast
# fi
/etc/profile.d/record-session.sh
if [ -n "$SSH_CONNECTION" ]; then
SESSION_LOG="/var/log/sessions/$(id -un)-$(date +%Y%m%d-%H%M%S).cast"
exec asciinema rec --quiet "$SESSION_LOG"
fi

Store casts somewhere with a retention policy. Don’t let that directory grow forever — find /var/log/sessions -mtime +90 -delete in a cron job will handle it.

Option 3: Warpgate (Do Everything, Eat Your Vegetables)

If you want recording, audit logs, MFA, and a web UI without duct-taping five tools together, Warpgate is the self-hosted answer. It’s written in Rust, actively maintained, and handles SSH, HTTP, and MySQL proxying with built-in session recording and MFA.

docker-compose.yml
services:
warpgate:
image: ghcr.io/warp-tech/warpgate:latest
ports:
- "8888:8888" # Web UI + HTTPS
- "2222:2222" # SSH proxy port
volumes:
- ./warpgate-data:/data
restart: unless-stopped

You configure targets (your internal hosts), users, and policies via the web UI or YAML. Users SSH to user:target@your-bastion:2222 — Warpgate proxies them through, records the session, enforces MFA. Everything lands in a searchable audit log.

Honestly? For a home lab, Warpgate is overkill. For a small team or a side project with paying customers, it’s the right call. It replaces Teleport’s core features without the enterprise licensing headache.

Other options in this space worth a look:


The Tailscale Pattern: When You Skip the Bastion Entirely

Here’s the thing about bastions: they solve a problem (port 22 exposed to the world) that Tailscale also solves, differently.

Tailscale creates a WireGuard mesh between your devices. Nothing is exposed to the public internet — your internal SSH hosts get a 100.x.x.x Tailscale IP that’s only reachable inside your tailnet. No bastion needed because there’s nothing to proxy through.

Tailscale ACL (tailscale.com/admin → Access Controls)
{
"acls": [
// Ops team can SSH to tagged servers
{
"action": "accept",
"src": ["group:ops"],
"dst": ["tag:server:22"]
}
],
"tagOwners": {
"tag:server": ["group:ops"]
}
}

Tag your servers with tag:server, restrict which groups can reach port 22, done. No bastions, no jump configs, no session recording setup unless you layer it on yourself.

When does Tailscale replace a bastion?

When does a bastion still make sense?

Both approaches are valid. Pick based on your threat model, not on what sounds more enterprise.


The Config That Ties It Together

Your working ~/.ssh/config for a hardened bastion setup:

~/.ssh/config
# Bastion — your one public-facing SSH host
Host bastion
HostName bastion.example.com
User ops
Port 22
IdentityFile ~/.ssh/id_ed25519
# No agent forwarding to bastion — ever
ForwardAgent no
# Keep connections alive
ServerAliveInterval 60
ServerAliveCountMax 3
# Reuse connections (speeds up ProxyJump)
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
# Internal hosts — auto-jump through bastion
Host 192.168.10.*
ProxyJump bastion
User ops
IdentityFile ~/.ssh/id_ed25519
ForwardAgent no
# Named aliases for your commonly-accessed hosts
Host app
HostName 192.168.10.10
ProxyJump bastion
User ops
Host db
HostName 192.168.10.20
ProxyJump bastion
User ops

ControlMaster is underrated. It keeps a master SSH connection to the bastion open and multiplexes subsequent connections over it. Your ProxyJump latency drops from 1-2 seconds to near-instant because the TCP handshake and key exchange only happen once.


Checklist Before You Call It Done

Stop opening port 22 to 0.0.0.0/0. Your auth logs will thank you.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Argo Workflows vs Tekton

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts