Configure an SSH bastion host to secure access to private networks, implementing jump server functionality with key-based authentication and access controls for enhanced security.
Prerequisites
- Root or sudo access to the bastion host server
- SSH client installed on user machines
- Basic understanding of SSH key management
What this solves
An SSH bastion host (or jump server) acts as a secure gateway between public networks and your private infrastructure. Instead of exposing multiple servers directly to the internet, you route all SSH connections through a single hardened bastion host, reducing attack surface and centralizing access control.
Step-by-step installation
Update system packages and install OpenSSH server
Start by updating your system and installing the SSH server if not already present.
sudo apt update && sudo apt upgrade -y
sudo apt install -y openssh-server fail2ban ufw
Create dedicated bastion user accounts
Create separate user accounts for each person who needs bastion access. Never use shared accounts for security auditing.
sudo useradd -m -s /bin/bash bastion-admin
sudo useradd -m -s /bin/bash bastion-dev
sudo mkdir -p /home/bastion-admin/.ssh /home/bastion-dev/.ssh
sudo chmod 700 /home/bastion-admin/.ssh /home/bastion-dev/.ssh
sudo chown bastion-admin:bastion-admin /home/bastion-admin/.ssh
sudo chown bastion-dev:bastion-dev /home/bastion-dev/.ssh
Configure SSH server hardening
Harden the SSH configuration by disabling password authentication, changing the default port, and restricting access methods.
# Change default SSH port
Port 2222
Protocol and security settings
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
UsePAM yes
Connection settings
ClientAliveInterval 300
ClientAliveCountMax 2
MaxAuthTries 3
MaxStartups 3:30:10
Allow specific users only
AllowUsers bastion-admin bastion-dev
Disable unnecessary features
X11Forwarding no
AllowTcpForwarding yes
GatewayPorts no
PermitTunnel no
Logging
SyslogFacility AUTH
LogLevel VERBOSE
Set up SSH key authentication
Add SSH public keys for each bastion user. Generate keys on client machines first, then add public keys to the bastion host.
# On client machine (generate if needed)
ssh-keygen -t ed25519 -C "user@example.com"
Copy public key content, then on bastion host:
sudo tee /home/bastion-admin/.ssh/authorized_keys << 'EOF'
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGQn8QVTzKbcXh5iMqBqB2tUxI5g7VzJ0yA5V2nE5P4h admin@example.com
EOF
sudo chmod 600 /home/bastion-admin/.ssh/authorized_keys
sudo chown bastion-admin:bastion-admin /home/bastion-admin/.ssh/authorized_keys
Configure firewall rules
Set up firewall rules to allow SSH on the custom port while blocking unnecessary access.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 2222/tcp comment 'SSH bastion'
sudo ufw enable
sudo ufw status
Configure Fail2ban for intrusion prevention
Set up Fail2ban to automatically block IP addresses that attempt multiple failed SSH connections.
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
ignoreip = 127.0.0.1/8 203.0.113.0/24
[sshd]
enabled = true
port = 2222
logpath = /var/log/auth.log
banaction = iptables-multiport
sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban
Restart SSH service with new configuration
Apply the SSH configuration changes by restarting the service.
sudo sshd -t
sudo systemctl restart sshd
sudo systemctl enable sshd
sudo systemctl status sshd
Set up jump server configuration for users
Configure SSH client settings on user machines to use the bastion host for accessing private servers.
# Bastion host configuration
Host bastion
HostName 203.0.113.10
Port 2222
User bastion-admin
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
ServerAliveCountMax 3
Private server through bastion
Host private-server
HostName 10.0.1.100
User ubuntu
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519
Wildcard for private network
Host 10.0.1.*
User ubuntu
ProxyJump bastion
IdentityFile ~/.ssh/id_ed25519
Configure SSH agent forwarding
Enable SSH agent forwarding to use local SSH keys when jumping to private servers, avoiding the need to store keys on the bastion host.
# Add to existing configuration
AllowAgentForwarding yes
# Update bastion host config
Host bastion
HostName 203.0.113.10
Port 2222
User bastion-admin
IdentityFile ~/.ssh/id_ed25519
ForwardAgent yes
ServerAliveInterval 60
Set up logging and monitoring
Configure comprehensive logging for security auditing and monitoring of bastion host access.
# Bastion host logging
auth,authpriv.* /var/log/bastion-auth.log
:programname, isequal, "sshd" /var/log/bastion-ssh.log
& stop
sudo systemctl restart rsyslog
sudo touch /var/log/bastion-auth.log /var/log/bastion-ssh.log
sudo chmod 640 /var/log/bastion-*.log
Create user access control script
Implement a script to manage user access and provide session logging for compliance.
#!/bin/bash
Bastion host session logger
USER=$(whoami)
CLIENT_IP=${SSH_CLIENT%% *}
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
SESSION_ID="${USER}-${RANDOM}"
echo "[$TIMESTAMP] User $USER connected from $CLIENT_IP (Session: $SESSION_ID)" >> /var/log/bastion-sessions.log
Display access banner
cat << EOF
===============================================
AUTHORIZED ACCESS ONLY
Bastion Host - example.com
Session ID: $SESSION_ID
All activities are logged and monitored
===============================================
EOF
Start shell with logging
script -a -f -q /var/log/bastion-sessions/$SESSION_ID.log
echo "[$TIMESTAMP] User $USER disconnected (Session: $SESSION_ID)" >> /var/log/bastion-sessions.log
sudo chmod +x /usr/local/bin/bastion-session
sudo mkdir -p /var/log/bastion-sessions
sudo touch /var/log/bastion-sessions.log
Configure user shell to use session script
Set the bastion session script as the default shell for bastion users to ensure all sessions are logged.
sudo chsh -s /usr/local/bin/bastion-session bastion-admin
sudo chsh -s /usr/local/bin/bastion-session bastion-dev
Verify your setup
Test your bastion host configuration from a client machine.
# Test direct bastion connection
ssh -p 2222 bastion-admin@203.0.113.10
Test jump connection to private server
ssh private-server
Test with verbose output for debugging
ssh -v private-server
Check active connections on bastion
sudo ss -tuln | grep :2222
sudo who
Review logs
sudo tail -f /var/log/bastion-auth.log
sudo tail -f /var/log/bastion-sessions.log
Common issues
| Symptom | Cause | Fix |
|---|---|---|
| Connection refused on port 2222 | Firewall blocking custom SSH port | Check firewall rules: sudo ufw status or sudo firewall-cmd --list-all |
| Permission denied (publickey) | SSH key not properly configured | Verify key permissions: chmod 600 ~/.ssh/authorized_keys and ownership |
| ProxyJump connection fails | SSH agent not running or forwarding disabled | Start SSH agent: eval $(ssh-agent) and add key: ssh-add ~/.ssh/id_ed25519 |
| Too many authentication failures | Multiple SSH keys being tried | Specify exact key in SSH config: IdentitiesOnly yes |
| Fail2ban not blocking attacks | Wrong log path or port configuration | Check Fail2ban status: sudo fail2ban-client status sshd |
Next steps
- Configure SSH port forwarding and tunneling for secure connections
- Configure Linux audit system with auditd for security compliance and file monitoring
- Set up centralized logging with rsyslog and logrotate for security events
- Configure SSH certificate authority for scalable key management
- Implement SSH bastion host high availability clustering
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Global variables
SCRIPT_NAME="$(basename "$0")"
SSH_PORT="${1:-2222}"
BASTION_USERS=("bastion-admin" "bastion-dev")
# Usage
usage() {
echo "Usage: $SCRIPT_NAME [SSH_PORT]"
echo " SSH_PORT: Custom SSH port (default: 2222)"
echo ""
echo "Example: $SCRIPT_NAME 2222"
exit 1
}
# Logging functions
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Cleanup function
cleanup() {
log_error "Installation failed. Performing cleanup..."
systemctl stop sshd 2>/dev/null || true
if [ -f /etc/ssh/sshd_config.backup ]; then
mv /etc/ssh/sshd_config.backup /etc/ssh/sshd_config
fi
}
trap cleanup ERR
# Validate arguments
if [[ $# -gt 1 ]] || [[ "${1:-}" =~ ^(-h|--help)$ ]]; then
usage
fi
if ! [[ "$SSH_PORT" =~ ^[0-9]+$ ]] || [ "$SSH_PORT" -lt 1024 ] || [ "$SSH_PORT" -gt 65535 ]; then
log_error "Invalid SSH port. Must be between 1024-65535"
exit 1
fi
# Check prerequisites
echo "[1/10] Checking prerequisites..."
if [ "$EUID" -ne 0 ]; then
log_error "This script must be run as root"
exit 1
fi
# Auto-detect distribution
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian)
PKG_MGR="apt"
PKG_UPDATE="apt update && apt upgrade -y"
PKG_INSTALL="apt install -y"
FIREWALL_CMD="ufw"
SERVICE_MGR="systemctl"
;;
almalinux|rocky|centos|rhel|ol|fedora)
PKG_MGR="dnf"
PKG_UPDATE="dnf update -y"
PKG_INSTALL="dnf install -y"
FIREWALL_CMD="firewall-cmd"
SERVICE_MGR="systemctl"
;;
amzn)
PKG_MGR="yum"
PKG_UPDATE="yum update -y"
PKG_INSTALL="yum install -y"
FIREWALL_CMD="firewall-cmd"
SERVICE_MGR="systemctl"
;;
*)
log_error "Unsupported distribution: $ID"
exit 1
;;
esac
else
log_error "Cannot detect distribution"
exit 1
fi
log_info "Detected distribution: $ID"
# Update system and install packages
echo "[2/10] Updating system and installing packages..."
$PKG_UPDATE
if [ "$PKG_MGR" = "apt" ]; then
$PKG_INSTALL openssh-server fail2ban ufw
else
$PKG_INSTALL openssh-server fail2ban firewalld
fi
# Create bastion user accounts
echo "[3/10] Creating bastion user accounts..."
for user in "${BASTION_USERS[@]}"; do
if ! id "$user" &>/dev/null; then
useradd -m -s /bin/bash "$user"
log_info "Created user: $user"
else
log_warn "User $user already exists"
fi
mkdir -p "/home/$user/.ssh"
chmod 700 "/home/$user/.ssh"
chown "$user:$user" "/home/$user/.ssh"
touch "/home/$user/.ssh/authorized_keys"
chmod 600 "/home/$user/.ssh/authorized_keys"
chown "$user:$user" "/home/$user/.ssh/authorized_keys"
done
# Backup SSH config
echo "[4/10] Configuring SSH server..."
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.backup
# Configure SSH hardening
cat > /etc/ssh/sshd_config << EOF
Port $SSH_PORT
Protocol 2
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
ChallengeResponseAuthentication no
UsePAM yes
ClientAliveInterval 300
ClientAliveCountMax 2
MaxAuthTries 3
MaxStartups 3:30:10
AllowUsers ${BASTION_USERS[*]}
X11Forwarding no
AllowTcpForwarding yes
GatewayPorts no
PermitTunnel no
SyslogFacility AUTH
LogLevel VERBOSE
Banner /etc/ssh/banner
EOF
# Create SSH banner
echo "[5/10] Creating SSH banner..."
cat > /etc/ssh/banner << 'EOF'
#######################################################################
# AUTHORIZED ACCESS ONLY #
# #
# This system is for authorized users only. All activities are #
# monitored and logged. Unauthorized access is strictly prohibited. #
#######################################################################
EOF
# Create bastion session script
echo "[6/10] Creating session logging script..."
mkdir -p /usr/local/bin /var/log/bastion-sessions
cat > /usr/local/bin/bastion-session << 'EOF'
#!/bin/bash
SESSION_LOG="/var/log/bastion-sessions/$(whoami)-$(date +%Y%m%d-%H%M%S).log"
echo "Session started: $(date)" >> "$SESSION_LOG"
echo "User: $(whoami)" >> "$SESSION_LOG"
echo "Client: ${SSH_CLIENT:-unknown}" >> "$SESSION_LOG"
echo "Command: $SSH_ORIGINAL_COMMAND" >> "$SESSION_LOG"
echo "---" >> "$SESSION_LOG"
if [ -n "$SSH_ORIGINAL_COMMAND" ]; then
echo "$SSH_ORIGINAL_COMMAND" >> "$SESSION_LOG"
exec $SSH_ORIGINAL_COMMAND
else
script -a -f "$SESSION_LOG" -c /bin/bash
fi
EOF
chmod 755 /usr/local/bin/bastion-session
# Configure user shells
echo "[7/10] Configuring user shells..."
for user in "${BASTION_USERS[@]}"; do
chsh -s /usr/local/bin/bastion-session "$user"
done
# Configure Fail2ban
echo "[8/10] Configuring Fail2ban..."
cat > /etc/fail2ban/jail.local << EOF
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 3
[sshd]
enabled = true
port = $SSH_PORT
logpath = /var/log/auth.log
EOF
# Configure firewall
echo "[9/10] Configuring firewall..."
if [ "$FIREWALL_CMD" = "ufw" ]; then
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow "$SSH_PORT"/tcp
ufw --force enable
else
systemctl enable --now firewalld
firewall-cmd --permanent --remove-service=ssh 2>/dev/null || true
firewall-cmd --permanent --add-port="$SSH_PORT"/tcp
firewall-cmd --reload
fi
# Start and enable services
systemctl enable --now sshd
systemctl enable --now fail2ban
# Test SSH configuration
echo "[10/10] Verifying configuration..."
if ! sshd -t; then
log_error "SSH configuration test failed"
exit 1
fi
systemctl restart sshd
# Final checks
if ! systemctl is-active --quiet sshd; then
log_error "SSH service is not running"
exit 1
fi
if ! systemctl is-active --quiet fail2ban; then
log_error "Fail2ban service is not running"
exit 1
fi
if ! ss -tuln | grep -q ":$SSH_PORT"; then
log_error "SSH is not listening on port $SSH_PORT"
exit 1
fi
log_info "SSH Bastion host setup completed successfully!"
echo ""
echo "Next steps:"
echo "1. Add SSH public keys to user authorized_keys files:"
for user in "${BASTION_USERS[@]}"; do
echo " sudo tee /home/$user/.ssh/authorized_keys < your_public_key.pub"
done
echo ""
echo "2. Test connection: ssh -p $SSH_PORT username@$(hostname -I | awk '{print $1}')"
echo "3. Configure client SSH config for ProxyJump"
echo ""
echo "Logs located at:"
echo "- SSH auth: /var/log/auth.log"
echo "- Session logs: /var/log/bastion-sessions/"
echo "- Fail2ban: sudo fail2ban-client status sshd"
Review the script before running. Execute with: bash install.sh