Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

SSH Bastion Host Patterns

By Kokil Thapa | Last reviewed: September 2026

SSH bastion host patterns solve a problem every production team hits once private servers stop accepting direct internet SSH. You expose one hardened jump box on a public subnet. Every other machine stays on private addresses. Operators and CI/CD pipelines reach them through that single gate. On real client projects I maintain on shared EC2 infrastructure, this pattern is how we deploy Laravel apps without opening port 22 on application servers. The sections below walk through the patterns that actually work in 2026, with copy-paste config you can drop into Ubuntu SSH server setup today.

What Are SSH Bastion Host Patterns and When Do You Need One?

A bastion host—also called a jump host or jump box—is a minimal Linux server whose only job is to relay SSH into a private network. It sits in a DMZ or public subnet. Your web servers, databases, and queue workers live behind it with no inbound SSH from the world.

You need this pattern when any of these apply:

  • Servers run on cloud VPCs with private-only application tiers, as in a typical Laravel on AWS EC2 layout.
  • Compliance or insurance asks for a single auditable SSH entry point.
  • Your team rotates keys and wants one place to enforce SSH key-only authentication.
  • Direct port 22 on every box creates too many attack surfaces for a small ops team.

You do not need a bastion for a single VPS where you are the only operator. One box, one firewall rule, one key pair—that is enough. The pattern pays off once you have three or more private hosts or shared deploy access.

SSH Bastion Host Pattern OverviewInternetDevelopersBastion HostPublic subnetApp ServerPrivate subnetQueue WorkerPrivate subnetDatabasePrivate subnet:22Private hosts: no inbound SSH from internetSecurity group allows SSH only from bastion private IP
Classic SSH bastion host pattern: one public jump box relays connections to private application, worker, and database tiers.

How Do You Configure SSH ProxyJump for a Bastion Host?

Modern OpenSSH supports ProxyJump (the -J flag). One command reaches a private host through the bastion. No manual two-step login. No shell session left open on the jump box unless you want one.

Client-side ~/.ssh/config

Put this on each developer laptop. Replace hostnames and keys with your values.

Host bastion
    HostName bastion.example.com
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes

Host app-*
    User deploy
    IdentityFile ~/.ssh/id_ed25519
    ProxyJump bastion
    StrictHostKeyChecking accept-new

Host app-web1
    HostName 10.0.2.15

Host app-web2
    HostName 10.0.2.16

Connect with ssh app-web1. OpenSSH opens a channel to the bastion first, then forwards to the private IP. The official OpenSSH manual documents ProxyJump and ProxyCommand at man.openbsd.org/ssh_config.5.

One-liner without config file

ssh -J deploy@bastion.example.com deploy@10.0.2.15

For scripts and CI pipelines, prefer the config file. Hard-coded jumps break when IPs change. Use DNS private records or inventory tags instead.

scp and rsync through the bastion

scp -J deploy@bastion.example.com ./release.tar.gz deploy@10.0.2.15:/var/www/

rsync -avz -e "ssh -J deploy@bastion.example.com" ./public/ deploy@10.0.2.15:/var/www/current/public/

Deployer 7 and GitLab CI jobs I run on sister legal-tech sites use this exact rsync-over-ProxyJump flow. The CI runner holds the deploy key. It never needs a VPN client installed.

ProxyJump Connection FlowSSH Client~/.ssh/configBastionAuth + relayPrivate Host10.0.2.1512Step-by-step1. Client authenticates to bastion with ed25519 key2. Bastion opens forward channel to private IP :223. Client re-authenticates to private host (same or deploy key)4. Encrypted session end-to-end; bastion sees metadata only
ProxyJump lets OpenSSH chain authentication through the bastion in one command instead of a manual two-hop login.

Which SSH Bastion Host Patterns Fit Different Network Layouts?

Not every team needs the same topology. Pick the pattern that matches your subnet design and compliance tier.

Pattern 1: Single bastion (most common)

One jump host in a public subnet. All private servers accept SSH only from the bastion security group. This covers most Laravel, WordPress, and WooCommerce stacks on a single VPC. I use it on the shared EC2 pipeline that deploys sites like Notary Kathmandu and related legal-tech portals.

Pattern 2: Bastion per environment

Separate jump boxes for production, staging, and development. Production bastion gets stricter IP allowlists and MFA. Staging may allow the whole office CIDR. Blast radius stays smaller when a staging key leaks.

Pattern 3: Multi-tier bastion chain

Large or regulated networks sometimes require two jumps: internet to DMZ bastion, then DMZ bastion to internal bastion, then app servers. Chain ProxyJump entries in ~/.ssh/config:

Host internal-bastion
    HostName 10.0.1.5
    User jump
    ProxyJump dmz-bastion

Host prod-db
    HostName 10.0.3.20
    User admin
    ProxyJump internal-bastion

Use this only when policy demands it. Each extra hop adds latency and debugging pain. Most Nepal SMB hosting setups never need it.

Pattern 4: Bastion with Session Manager overlay

On AWS, some teams pair a traditional bastion with SSM Session Manager for break-glass access when SSH keys fail. The bastion remains the primary path for Deployer and rsync. SSM covers emergency console access without opening port 22 wider. AWS documents bastion host guidance in their Linux bastion Quick Start.

PatternBest forOps overheadSecurity posture
Single bastion1–20 private servers, single VPCLowGood with hardening
Per-environment bastionProd/staging isolationMediumBetter blast-radius control
Multi-tier chainRegulated enterprise, multi-DMZHighStrong segmentation
Bastion + SSM break-glassAWS-heavy teamsMediumGood resilience
No bastion (VPN only)Full-tunnel remote workforceMedium–highDepends on VPN hygiene

How Do You Harden a Bastion Host for Production SSH?

The bastion is your highest-value SSH target. Compromise it and an attacker can reach every private box that trusts it. Harden it harder than any other server.

Baseline hardening checklist

  1. Disable password authentication and root login in /etc/ssh/sshd_config.
  2. Allow only Ed25519 or ECDSA keys; retire weak RSA below 4096 bits.
  3. Restrict AllowUsers or AllowGroups to named deploy accounts.
  4. Bind SSH to a non-default port only if your firewall logs help you; security through obscurity alone is worthless.
  5. Install fail2ban with sshd jails, as covered in SSH hardening with fail2ban.
  6. Enable automatic security updates on Ubuntu 22.04 or 24.04.
  7. Ship auth logs to a central syslog or CloudWatch—never rely on local rotation alone.

Example sshd fragment for the bastion:

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
AllowTcpForwarding yes
X11Forwarding no
AllowAgentForwarding no

Set AllowAgentForwarding no on the bastion unless you have a documented reason. Agent forwarding from a laptop through a bastion to a production box is a common lateral-movement path. See SSH agent forwarding risks for safer alternatives like short-lived certificates.

Security group and firewall rules

On the bastion: allow inbound TCP 22 (or your chosen port) only from known office IPs, VPN egress, or CI runner ranges. On private app servers: allow inbound 22 only from the bastion private IP or its security group ID. Deny everything else. Outbound from the bastion to private subnets on 22 should be explicit—not a wide 0.0.0.0/0 egress rule.

Keep the bastion minimal

Do not run nginx, MySQL, or Redis on the jump box. No customer data. No cron jobs beyond log shipping and patching. A smaller package list means fewer CVEs. Patch monthly at minimum; weekly if you expose SSH to broad IP ranges.

Access Pattern ComparisonBastion HostSingle SSH entryGreat for CI/CDLow client setupSite-to-Site VPNFull network tunnelHeavier opsGood for officesDirect SSHEvery host publicLarge attack surfaceAvoid in productionRecommendation for web app teamsUse bastion for server admin and deploy pipelinesAdd VPN only if staff need full private network accessNever expose database tier SSH to the internet
Bastion host versus VPN versus direct SSH: bastions fit deploy automation and small ops teams best.

How Should CI/CD and Deploy Pipelines Use a Bastion Host?

Automated deploys need the same jump path as humans. Treat the CI runner like a machine user with its own key pair—never reuse a developer laptop key.

GitLab CI example

deploy_production:
  stage: deploy
  image: alpine:latest
  before_script:
    - apk add --no-cache openssh-client rsync
    - eval "$(ssh-agent -s)"
    - echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
    - mkdir -p ~/.ssh && chmod 700 ~/.ssh
    - echo "$SSH_CONFIG" > ~/.ssh/config && chmod 600 ~/.ssh/config
  script:
    - dep deploy production
  only:
    - main

Store the private key and a minimal SSH_CONFIG with ProxyJump in GitLab CI variables. Rotate the deploy key when staff leave. The same pattern applies to GitHub Actions with webfactory/ssh-agent or native ssh-agent setup.

Deployer 7 host definition

host('production')
    ->setHostname('10.0.2.15')
    ->setRemoteUser('deploy')
    ->set('ssh_arguments', ['-o ProxyJump=deploy@bastion.example.com'])
    ->setDeployPath('/var/www/app');

After symlink swap, reload PHP-FPM over the same jump path. I have seen deployments succeed but cron still pointing at an old release path because someone ran crontab edits outside the bastion workflow. Keep all server access consistent.

For tunnel-based database migrations during deploy, use local port forwarding through the bastion instead of opening MySQL to the internet. That pairs well with guidance in SSH tunneling and port forwarding:

ssh -J deploy@bastion.example.com -L 3307:10.0.3.20:3306 deploy@10.0.2.15 -N

Connect your migration tool to 127.0.0.1:3307. The tunnel closes when the session ends.

What Are Common SSH Bastion Host Mistakes to Avoid?

These failures show up repeatedly on production Laravel and WordPress hosts I troubleshoot.

  • Shared personal keys on the bastion. Every operator should use their own key. Shared keys break audit trails. Generate strong keys with a password generator tool for passphrases, not for SSH key material itself—use ssh-keygen -t ed25519.
  • Agent forwarding enabled globally. An attacker who lands on the bastion can pivot through forwarded agents. Disable it server-side.
  • No session logging. Install auditd or use structured auth logging. You need to know who reached which private IP and when.
  • Bastion used as an application server. Disk fills up, Apache gets installed, and suddenly the jump box runs PHP. Do not do this.
  • Forgetting outbound rules. Inbound hardening on private hosts is useless if the bastion can SSH to the entire RFC1918 space uncontrolled.
  • Skipping key rotation. Rotate deploy keys quarterly or on any staff change. For larger teams, move toward SSH certificate authorities with short TTLs.

Another subtle issue: stale known_hosts entries after rebuilds. Private IPs get reused. Use HashKnownHosts yes on clients and update entries when you reprovision.

CI/CD Through BastionGitLab CIDeploy keyBastionProxyJumpApp ServerDeployer 7PHP-FPMReloadDeploy pipeline steps1. git push triggers pipeline on protected branch2. CI loads SSH config with ProxyJump to bastion3. Deployer rsyncs release over SSH tunnel chain4. Symlink swap + php-fpm reload on private host5. Rollback via dep rollback uses same bastion path
GitLab CI and Deployer 7 deployments through an SSH bastion host keep private servers off the public internet.

If you outgrow a single bastion—high concurrency deploys, many simultaneous operator sessions—consider a second bastion behind a load balancer for availability. That moves into high-availability architecture territory. Document which bastion IP your security groups reference.

Key Takeaways

  • Place one hardened bastion in a public subnet; private servers accept SSH only from that host.
  • Use OpenSSH ProxyJump in ~/.ssh/config so developers and CI share the same path.
  • Disable password auth, agent forwarding, and root login on the bastion; log every session.
  • Give CI/CD its own deploy key—never mount a human laptop key into GitLab variables.
  • Pick single-bastion for most VPCs; add tiers only when policy requires segmentation.
  • Pair bastion access with SSH hardening on Linux across all tiers, not just the jump box.

People Also Ask

What is the difference between a bastion host and a jump server?

They are the same thing. "Bastion" emphasises the military DMZ idea—a fortified entry point. "Jump server" or "jump box" describes the SSH relay behaviour. Documentation and cloud providers mix the terms freely.

Can you use a bastion host without opening port 22 to the internet?

Yes. Restrict inbound SSH on the bastion to your office CIDR, a VPN egress IP, or a Zero Trust edge. Some teams replace public SSH entirely with WireGuard plus an internal jump. The bastion pattern still applies; only the reachability of port 22 changes.

Is a bastion host better than a VPN for server access?

They solve different problems. A bastion gives you a narrow SSH gate ideal for deploy scripts and occasional admin. A VPN puts the client inside the private network for many protocols. Most web teams need a bastion first. Add VPN later if staff need direct access to internal HTTP tools or databases beyond SSH.

How much does a bastion host cost to run?

On AWS, a t3.micro or t4g.nano bastion costs roughly Rs 800–1,500/month (~USD 6–11) plus a small EBS volume. The real cost is operational: patching, monitoring, and key rotation. For Nepal SMB budgets, that is usually cheaper than a managed VPN appliance.

Build a Bastion Layer Your Deploy Pipeline Can Trust

SSH bastion host patterns are not exotic infrastructure. They are the minimum sane gate between the internet and your private Laravel, WordPress, or eCommerce servers. Start with one jump box, ProxyJump in team SSH config, security groups that trust only the bastion, and a dedicated CI deploy key. Harden the bastion first; private tiers second. If you want help designing this for a production stack—or wiring Deployer and GitLab CI through a jump host—Linux system administration support and ongoing server maintenance are where I usually start with clients. See deployed examples on the Adventure Third Pole Trek portfolio entry, or contact us to review your current SSH exposure.

Frequently Asked Questions

One hardened jump server in a public subnet relays SSH into private hosts. Application servers deny direct internet SSH and accept connections only from the bastion.

You need this pattern when servers sit on cloud VPCs with private application tiers, compliance requires a single auditable SSH entry point, your team enforces key-only authentication centrally, or direct port 22 on every box creates too many attack surfaces for a small ops team. You do not need it for a single VPS where you are the only operator. The pattern pays off once you have three or more private hosts or shared deploy access across a team.

They are the same thing. Bastion emphasises the DMZ fortified-entry idea; jump server or jump box describes the SSH relay behaviour.

Modern OpenSSH supports ProxyJump via the -J flag or a ~/.ssh/config block. Define a Host entry for the bastion with HostName, User, and IdentityFile, then set ProxyJump bastion on private host entries with their private IPs. Connect with ssh app-web1 and OpenSSH chains authentication through the bastion in one command. For scripts and CI pipelines, prefer the config file over hard-coded jumps, because IPs change. Use DNS private records or inventory tags instead of fixed addresses.

Single bastion suits one VPC with one to twenty private servers and low ops overhead. Per-environment bastions isolate production, staging, and development with stricter prod IP allowlists and smaller blast radius if a staging key leaks. Multi-tier chains add internet-to-DMZ-to-internal hops for regulated enterprise networks, but each extra hop adds latency and debugging pain. Bastion plus AWS SSM Session Manager gives break-glass access when SSH keys fail. VPN-only access works for full-tunnel remote workforces but depends on VPN hygiene.

Disable password authentication and root login in /etc/ssh/sshd_config. Allow only Ed25519 or ECDSA keys and retire weak RSA below 4096 bits. Restrict AllowUsers or AllowGroups to named deploy accounts. Install fail2ban with sshd jails, enable automatic security updates on Ubuntu 22.04 or 24.04, and ship auth logs to central syslog or CloudWatch. Set AllowAgentForwarding no unless documented, because agent forwarding from a laptop through a bastion is a common lateral-movement path. Keep the bastion minimal with no nginx, MySQL, Redis, customer data, or application cron jobs.

On the bastion, allow inbound TCP 22 only from known office IPs, VPN egress, or CI runner ranges. On private app servers, allow inbound 22 only from the bastion private IP or its security group ID and deny everything else. Outbound from the bastion to private subnets on port 22 should be explicit, not a wide 0.0.0.0/0 egress rule. Document which bastion IP your security groups reference, especially after reprovisioning, because private IPs get reused and stale known_hosts entries cause connection failures.

Treat the CI runner like a machine user with its own key pair and never reuse a developer laptop key. In GitLab CI, install openssh-client and rsync, load the private key via ssh-agent, and store a minimal SSH_CONFIG with ProxyJump in CI variables. Deployer 7 host definitions use ssh_arguments with -o ProxyJump=deploy@bastion.example.com to reach private IPs. The same pattern applies to GitHub Actions with ssh-agent setup. Rotate the deploy key when staff leave. Keep all server access, including cron edits and PHP-FPM reloads after symlink swap, consistent through the bastion workflow.

Use the -J flag to chain through the bastion in one command. For scp, pass -J deploy@bastion.example.com with the private host IP and destination path. For rsync, set -e to ssh -J deploy@bastion.example.com so the sync runs over the same jump path. Deployer 7 and GitLab CI jobs on production Laravel sites use this exact rsync-over-ProxyJump flow. The CI runner holds the deploy key and never needs a VPN client installed, which keeps automated deploys aligned with how developers connect manually.

Use local port forwarding through the bastion instead of opening MySQL to the internet. Run ssh with -J deploy@bastion.example.com and -L 3307:10.0.3.20:3306 to the app server with -N to hold the tunnel open. Connect your migration tool to 127.0.0.1:3307. The tunnel closes when the session ends. This pairs well with deploy workflows where the database sits on a private subnet and only the bastion path reaches application servers. Never expose database ports directly when a jump host already provides controlled access.

Shared personal keys on the bastion break audit trails; every operator should use their own Ed25519 key from ssh-keygen. Agent forwarding enabled globally lets attackers pivot through forwarded agents, so disable it server-side. No session logging via auditd or structured auth logging means you cannot trace who reached which private IP. Running nginx, PHP, or cron jobs on the jump box turns it into an application server. Forgetting outbound rules leaves the bastion able to SSH across all RFC1918 space. Skipping quarterly key rotation or post-departure rotation is another recurring failure on production Laravel and WordPress hosts.

They solve different problems. A bastion gives you a narrow SSH gate ideal for deploy scripts, rsync releases, and occasional admin work by small ops teams. A VPN puts the client inside the private network for many protocols beyond SSH, such as internal HTTP tools or direct database clients. Most web teams need a bastion first for Deployer and GitLab CI automation. Add VPN later if staff need broader internal network access. Bastions fit deploy automation and small ops teams best; VPN hygiene determines whether VPN-only access is sufficient.

A t3.micro or t4g.nano bastion costs roughly Rs 800–1,500 per month (~USD 6–11) plus a small EBS volume.

Yes. Restrict inbound SSH on the bastion to your office CIDR, a VPN egress IP, or a Zero Trust edge instead of 0.0.0.0/0. Some teams replace public SSH entirely with WireGuard plus an internal jump, but the bastion relay pattern still applies; only the reachability of port 22 changes. Production bastions in per-environment setups often get stricter IP allowlists and MFA on production while staging may allow the whole office CIDR. The goal is one auditable entry point, not necessarily a globally open SSH port.

Use a multi-tier chain only when policy demands segmentation in large or regulated networks that require two jumps: internet to DMZ bastion, then DMZ bastion to internal bastion, then app servers. Chain ProxyJump entries in ~/.ssh/config for each hop. Most Nepal SMB hosting setups and single-VPC Laravel stacks never need it, because each extra hop adds latency and debugging pain. Single bastion covers one to twenty private servers with low ops overhead and good security when hardened properly. Reserve multi-tier chains for regulated enterprise multi-DMZ layouts where blast-radius control overrides simplicity.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: