
Your biggest SSH risk usually isn’t a zero-day. It’s operational drift: too many servers exposed to the internet, too many keys floating around, and not enough visibility into who logged in—or what happened after. An SSH bastion host setup fixes that by making a single hardened entry point the only route into production.
This is an opinionated, production-leaning guide for 2026. You’ll get concrete configs, quick verification steps, and rollback notes you’ll actually use. No lab demo fluff—just a pattern you can run for a small SaaS, an internal API fleet, or a couple of database boxes.
What you’re building (and why it works)
The architecture is simple: only the bastion (jump box) accepts inbound SSH from the public internet. Every other server accepts SSH only from the bastion’s private IP (or a private network). Developers and sysadmins connect like this:
- Developer laptop → Bastion (public) using per-user keys and MFA
- Bastion → Target servers (private) using short, controlled forwarding (ProxyJump / agent forwarding avoided)
When you stick to that shape, three benefits show up immediately:
- Reduced attack surface: one SSH endpoint to harden, rate-limit, and monitor.
- Centralized auditing: bastion logs become your “front door camera.”
- Faster offboarding: remove one user from the bastion, and they’re out of prod.
Prerequisites and the specific scenario used in this post
To keep the examples grounded, this post uses a small, realistic layout. Swap names and IPs to match your environment.
- Bastion: Debian 13, public IP
203.0.113.10, private IP10.20.0.10 - App server: Debian 13, private IP
10.20.0.21 - DB server: Debian 13, private IP
10.20.0.31 - SSH port: keep
22(changing ports is not a control) - MFA: TOTP via PAM (
libpam-google-authenticator) - Audit: OpenSSH logs + optional keystroke/session recording using
tlog
You’ll also want:
- A VPS you control for the bastion. A small instance is fine, but choose stable CPU and fast storage. A HostMyCode VPS is a solid fit for a dedicated jump box.
- Private networking (preferred) or at least strict firewall rules.
- SSH client on your workstation (OpenSSH 9.6+ recommended in 2026).
SSH bastion host setup: a hardened baseline you can live with
This baseline holds up under real operations: key-only login, explicit allow-lists, tight forwarding rules, and a firewall that enforces the architecture even if someone later “just tweaks sshd for a minute.”
Harden the bastion’s SSH daemon (sshd)
On the bastion, edit /etc/ssh/sshd_config and keep it explicit. The snippet below is strict on purpose, but still workable for a team.
# /etc/ssh/sshd_config (bastion)
Port 22
Protocol 2
# No password auth
PasswordAuthentication no
KbdInteractiveAuthentication yes
UsePAM yes
# Root login off
PermitRootLogin no
# Reduce exposed features
X11Forwarding no
AllowTcpForwarding no
PermitTunnel no
GatewayPorts no
PermitUserEnvironment no
# Keep it fast to fail brute force
MaxAuthTries 3
LoginGraceTime 20
# Log detail for incident response
LogLevel VERBOSE
# Only allow a dedicated group
AllowGroups bastion-ssh
# Modern crypto (OpenSSH defaults are good in 2026)
PubkeyAuthentication yes
Create the group and add the users you want to permit:
sudo groupadd bastion-ssh
sudo usermod -aG bastion-ssh alice
sudo usermod -aG bastion-ssh bob
Restart and confirm the daemon is healthy:
sudo sshd -t
sudo systemctl restart ssh
sudo systemctl status ssh --no-pager
Enforce the network shape with a firewall (don’t rely on sshd alone)
Good sshd settings help, but firewall rules make the design hard to accidentally break. Treat them as the guardrails.
On the bastion, allow SSH only from your office IPs/VPN egress. Example using UFW:
sudo ufw default deny incoming
sudo ufw default allow outgoing
# Allow SSH only from known egress IPs
sudo ufw allow from 198.51.100.50 to any port 22 proto tcp
sudo ufw allow from 198.51.100.51 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
On the app and DB servers, block public SSH entirely and allow SSH only from the bastion’s private IP:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 10.20.0.10 to any port 22 proto tcp
sudo ufw enable
sudo ufw status verbose
If you want a fuller, SSH-safe firewall workflow, the playbook in UFW Firewall Setup for a VPS in 2026 is a good companion. The non-negotiable here is the shape: only the bastion is reachable from the internet.
Make SSH client behavior consistent with ProxyJump
Bastions often fall apart because everyone connects a little differently. Fix that early with a shared SSH config template and enforce it in onboarding.
On your workstation, create/update ~/.ssh/config:
# ~/.ssh/config
Host hmc-bastion
HostName 203.0.113.10
User alice
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519_hmc
ServerAliveInterval 30
ServerAliveCountMax 3
Host app-01
HostName 10.20.0.21
User deploy
ProxyJump hmc-bastion
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519_hmc
Host db-01
HostName 10.20.0.31
User ops
ProxyJump hmc-bastion
IdentitiesOnly yes
IdentityFile ~/.ssh/id_ed25519_hmc
After that, connecting should feel uneventful:
ssh app-01
ssh db-01
MFA on the bastion: TOTP without turning SSH into a helpdesk ticket
Put MFA on the bastion because it’s the choke point. You can protect the whole fleet without rolling MFA onto every box on day one. In 2026, TOTP via PAM remains a practical baseline for small teams that don’t have a full SSO pipeline.
Install and configure PAM TOTP
On Debian 13 bastion:
sudo apt update
sudo apt install -y libpam-google-authenticator
For each user, initialize TOTP:
su - alice
google-authenticator
Suggested answers for a bastion:
- Time-based tokens: Yes
- Update
~/.google_authenticator: Yes - Disallow multiple uses: Yes
- Increase skew window: Yes (small teams travel; clocks drift)
- Rate-limiting: Yes
Then add the PAM module to SSH. Edit /etc/pam.d/sshd and add near the top:
auth required pam_google_authenticator.so nullok
Note: nullok lets users without a token file log in. That’s helpful during a staged rollout; remove it once everyone is enrolled.
Finally, ensure sshd allows keyboard-interactive (we already set KbdInteractiveAuthentication yes). Restart SSH:
sudo sshd -t
sudo systemctl restart ssh
Verification: confirm MFA is actually enforced
From a workstation, attempt to SSH into the bastion:
ssh hmc-bastion
Expected prompts look like:
Verification code:
Then connect onward:
ssh app-01
If you see password prompts on the bastion, treat it as a break-fix issue. Password auth attracts credential stuffing, and you’ll feel it in the logs.
Auditing and session visibility: logs you’ll use during an incident
A bastion you can’t audit quickly turns into a single point of regret. At minimum, you want: who logged in, from where, which key, and what they accessed next.
Turn on useful SSH logging (and keep it readable)
We already set LogLevel VERBOSE on the bastion. That adds key fingerprint details to auth logs. On Debian, review:
sudo journalctl -u ssh --since "1 hour ago" --no-pager
Look for entries that include public key fingerprints and remote IPs. That’s your baseline.
Optional: record interactive sessions with tlog
If you operate regulated workloads—or you’ve lived through a “we don’t know what changed” outage—session recording can be worth the friction. tlog is a lightweight option that captures terminal I/O. It won’t replace change management, but it can shorten investigations.
Install:
sudo apt update
sudo apt install -y tlog
Then configure it to wrap shells for the bastion-ssh group (approach varies by distro and policy). A common operational model is to use ForceCommand for a dedicated bastion user role, but that’s more invasive. If you want full keystroke capture, test carefully in a staging bastion first.
For broader logging pipelines, ship bastion logs into whatever you already run. If you’re centralizing logs today, the pattern in VPS Log Shipping with Vector fits neatly here because journald is your primary source.
Safer access to targets: avoid agent forwarding, prefer per-host keys
Agent forwarding feels handy until the bastion is compromised. At that point, a forwarded agent becomes a pivot. The safer default in 2026 is still the boring one: no agent forwarding, no shared private keys sitting on the bastion, and clear per-host authorization.
Two workable patterns:
- Pattern A (recommended): developers connect to targets through ProxyJump, using their local keys. Targets authorize their public keys directly.
- Pattern B: bastion holds a controlled key used only to reach targets, and access to that key is mediated (harder to do safely without extra tooling).
Pattern A scales well for small-to-medium fleets and keeps private keys off the bastion.
Use per-user keys, and document fingerprints
Create a dedicated key for production access on your workstation:
ssh-keygen -t ed25519 -a 64 -f ~/.ssh/id_ed25519_hmc -C "alice@prod-bastion"
Capture the fingerprint in your access request or ticket:
ssh-keygen -lf ~/.ssh/id_ed25519_hmc.pub
Expected output format:
256 SHA256:... alice@prod-bastion (ED25519)
Common failure modes (and how to avoid them)
Bastions rarely fail because the idea is flawed. They fail because small exceptions pile up until the “temporary” path becomes the default.
- Leaving port 22 open to the world: restrict by IP/VPN on day one. If your team travels, put a VPN in front rather than widening SSH exposure.
- Turning on agent forwarding “temporarily”: it sticks around forever. Use ProxyJump with local keys instead.
- Mixing personal and production keys: you lose audit clarity. Use a dedicated key per environment.
- MFA rollout without a break-glass plan: keep a tested emergency account stored in your password vault, restricted to a known source IP and monitored.
- No restore tests for bastion config: treat bastion config as code; back it up and be ready to rebuild quickly.
For hardening beyond SSH (sysctl, unattended upgrades, service minimization), hold the bastion to the same standard as any production server. The tutorial How to Harden Your Linux VPS for Production in 2026 is a solid checklist to align with.
Rollback plan: how to back out without locking yourself out
You should be able to back out of bastion changes fast—especially MFA and sshd restrictions. The safest rollback plan is staged and tested, not improvised during an outage.
- Keep an active root console path via your VPS provider (out-of-band access). Confirm you can reach it before changing auth.
- Apply changes in two SSH sessions: leave one session logged in while you test the second.
- Rollback sshd_config by keeping a known-good copy:
sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%F-%H%M) - If MFA breaks logins, revert PAM change first by commenting the TOTP line in
/etc/pam.d/sshd, then restart SSH. - If firewall rules block you, use the provider console to disable UFW temporarily:
sudo ufw disable
Rollback stays clean when the bastion is disposable. Keep configuration in a private Git repo, and prefer rebuilding the instance over nursing a mystery state.
Performance and reliability notes (bastions are small, but not trivial)
A bastion is rarely CPU-bound, but it’s latency-sensitive. If SSH feels slow or flaky, people will route around it—and that’s how “exceptions” become permanent.
- Choose a region close to your servers (and ideally close to your team’s VPN egress). Latency adds up: laptop → bastion → target.
- Keep the bastion clean: no application workloads, no random Docker containers, no “temporary” dev tools.
- Monitor auth failures and connection counts: a spike often precedes an incident.
If you want deeper visibility into network drops or CPU hotspots on the bastion, pair your setup with Linux VPS monitoring with eBPF. It’s useful the first time someone reports “SSH is flaky” and you need proof.
Where HostMyCode fits in this architecture
A bastion benefits from predictable networking, clean OS images, and fast recovery options. If you’re using this pattern for production access, a managed VPS hosting plan can offload baseline patching and routine hygiene while you keep full control of SSH policy and auditing.
If you prefer to run everything yourself, start with a standard HostMyCode VPS and treat it like immutable infrastructure: config committed, changes reviewed, rebuilds preferred over hand-edits.
If you’re standardizing secure access across a small fleet, keep the bastion on a stable VPS and separate it from application workloads. Start with a HostMyCode VPS, and consider managed VPS hosting if you want help staying current on OS updates and baseline hardening.
FAQ
Do I need more than one bastion?
If you operate multiple regions or need high availability for on-call access, run two bastions (one per region) and document the failover. For smaller teams, one bastion is fine if you also have provider console access as a break-glass path.
Should I change the SSH port on the bastion?
No. It reduces noise in logs but doesn’t reduce real risk. Spend the effort on key-only auth, IP allow-lists/VPN, MFA, and monitoring.
Is MFA on the bastion enough?
It’s a strong first step because it protects the primary entry point. For higher assurance, add MFA to privileged actions (sudo policy), require short-lived certificates (SSH CA), and record sessions for critical environments.
How do I prove targets are only reachable through the bastion?
From an external network, try to SSH directly to a target. It should fail at the network level (timeout/refused). From the bastion, the same SSH should succeed. That difference is the whole point.
What’s the cleanest next upgrade after this setup?
Move from static keys to an SSH CA with short-lived user certificates, then integrate issuance with your identity provider. That gives you time-bound access and faster offboarding without hunting for old keys.
Next steps (keep improving without boiling the ocean)
- Add restore discipline: back up bastion configs and test recovery. If you haven’t built a routine yet, align it with VPS Backup Strategy 2026.
- Introduce SSH certificates: shift to short-lived certs to reduce long-lived key risk.
- Centralize logs and alerting: alert on repeated failures, new geo/IP sources, and out-of-hours access.
- Document break-glass: who can use it, how it’s monitored, and how often it’s tested.
Summary
An SSH bastion host setup is an unglamorous control that earns its keep the first time you need to answer, “Who accessed production, from where, and how?” Keep the bastion minimal, enforce the architecture with firewall rules, require MFA, and make ProxyJump the default path for your team.
If you want a straightforward place to run that jump box, deploy it on a HostMyCode VPS and treat it as a dedicated security component—not “just another server.”