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.
# Single hop: jump through bastion to reach internal host
# Multi-hop: chain through two jumpsThat’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:
# The bastion itselfHost bastion HostName bastion.example.com User deploy IdentityFile ~/.ssh/id_ed25519_bastion Port 22
# All internal hosts — jump through bastion automaticallyHost 192.168.10.* ProxyJump bastion User deploy IdentityFile ~/.ssh/id_ed25519_internal
# Or match by aliasHost app-server HostName 192.168.10.50 ProxyJump bastion User deployNow 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:
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
# No password auth — keys onlyPasswordAuthentication noChallengeResponseAuthentication noPermitRootLogin no
# Limit to your ops usersAllowGroups sshusers
# Kill idle sessionsClientAliveInterval 300ClientAliveCountMax 2
# No X11, no agent forwarding on the bastion itselfX11Forwarding noAllowAgentForwarding no
# Force a specific key exchange for compliance nerdsKexAlgorithms curve25519-sha256,ecdh-sha2-nistp521Ciphers [email protected],[email protected]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:
# Sign a user cert valid for 8 hours, only valid for the bastion hostssh-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.pubThe -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:
# Installapt install auditd audispd-plugins
# Watch exec calls from sshd childrenauditctl -a always,exit -F arch=b64 -S execve -F uid>=1000 -k ssh_exec
# Check logsausearch -k ssh_exec --start today | aureport -f -iThe 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:
# Installpip install asciinema
# Record a session manuallyasciinema 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# fiif [ -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"fiStore 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.
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-stoppedYou 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:
- SSHwifty — browser-based SSH client, good for read-only access or letting non-technical people in
- Boundary (HashiCorp) — identity-based access, complex, built for larger teams
- Trasa — zero-trust access proxy, less active development but full-featured
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.
{ "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?
- Home lab with a handful of servers you personally manage
- Small team where everyone already has Tailscale installed
- You trust Tailscale’s control plane (you’re giving them your network topology — that’s the trade)
When does a bastion still make sense?
- Compliance requirements (SOC2, PCI) that mandate session recording and auditable SSH access
- Third-party contractors who shouldn’t have full Tailscale access
- Air-gapped environments where you control every hop
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:
# Bastion — your one public-facing SSH hostHost 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 bastionHost 192.168.10.* ProxyJump bastion User ops IdentityFile ~/.ssh/id_ed25519 ForwardAgent no
# Named aliases for your commonly-accessed hostsHost app HostName 192.168.10.10 ProxyJump bastion User ops
Host db HostName 192.168.10.20 ProxyJump bastion User opsControlMaster 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
- Port 22 open on bastion only — all other hosts firewall it
PasswordAuthentication noon every SSH host- SSH certificates with TTL, not static
authorized_keysfiles AllowAgentForwarding noon the bastion- Session recording running (Asciinema + cron cleanup, or Warpgate)
auditdlogging exec calls- Fail2ban or equivalent on the bastion for the inevitable brute force
- MFA on bastion logins (see PAM TOTP guide)
- ControlMaster in your ssh_config because life’s too short for repeated handshakes
Stop opening port 22 to 0.0.0.0/0. Your auth logs will thank you.