BLOG // [DEVOPS] Automating SSH with Bash: Managing Multiple Servers in DevOps
Automating SSH with Bash: Managing Multiple Servers in DevOps

Automating SSH with Bash: Managing Multiple Servers in DevOps

Comprehensive guide to automating SSH with Bash for multi-server management: ssh-keygen, ssh-copy-id, ~/.ssh/config, scp, rsync, and parallel remote execution with & and wait.

DEVOPS BASH

Automating SSH with Bash in DevOps

In the previous post, we explored monitoring system resources and managing processes on a single server. However, in real-world DevOps, you rarely manage just one machine. As infrastructure scales to 10, 50, or even hundreds of servers, manually SSHing into each machine to run commands becomes a nightmare—slow, error-prone, and impossible to reproduce consistently.

Automating SSH with Bash solves this: write once, run across every server, with consistent results that can integrate into CI/CD pipelines or cron jobs. This article covers setting up SSH key-based authentication, configuring ~/.ssh/config for multi-server management, transferring files securely with scp and rsync, and includes a practical script for parallel remote execution across multiple servers.


Setting Up SSH Key-Based Authentication

SSH key-based authentication eliminates password prompts on every connection—a mandatory requirement for any automation script.

Generate an SSH Key Pair

1
2
3
4
5
# Generate RSA 4096-bit key
ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa_deploy -C "deploy@automation"

# Or use Ed25519 (faster, more secure)
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_deploy -C "deploy@automation"

Note: If you use a key for automation, set an empty passphrase (press Enter when prompted) or use ssh-agent to manage passphrases. However, in CI/CD environments, use a passphrase-less key and protect it with filesystem permissions.

Copy the Key to Target Servers

1
2
3
4
5
6
# Standard method using ssh-copy-id
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub user@server1
ssh-copy-id -i ~/.ssh/id_ed25519_deploy.pub user@server2

# Manual method if ssh-copy-id is unavailable
cat ~/.ssh/id_ed25519_deploy.pub | ssh user@server1 "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"

Verify the Connection

1
2
# Test passwordless SSH
ssh -i ~/.ssh/id_ed25519_deploy user@server1 "echo 'SSH connection successful'"

Configuring ~/.ssh/config

The ~/.ssh/config file lets you access servers using short aliases instead of memorizing IP addresses and usernames:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# ~/.ssh/config

# Staging servers
Host staging-web
    HostName 192.168.1.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 22

Host staging-db
    HostName 192.168.1.11
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 22

# Production servers
Host prod-web
    HostName 10.0.0.10
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 2222

Host prod-db
    HostName 10.0.0.11
    User deploy
    IdentityFile ~/.ssh/id_ed25519_deploy
    Port 2222

# Global defaults
Host *
    ServerAliveInterval 60
    ServerAliveCountMax 3
    StrictHostKeyChecking accept-new
    LogLevel ERROR

After configuration, access servers using short host names:

1
2
3
4
5
# Instead of: ssh [email protected]
ssh staging-web "df -h /"

# Or add a Bash alias
echo "alias prod-web='ssh prod-web'" >> ~/.bashrc

Key configuration explained:

  • ServerAliveInterval 60: Send keepalive every 60 seconds to prevent disconnection.
  • ServerAliveCountMax 3: Disconnect after 3 failed keepalive attempts.
  • StrictHostKeyChecking accept-new: Automatically accept new host keys without prompting.
  • LogLevel ERROR: Show only errors, reducing verbosity in scripts.

Remote Command Execution

Basic Commands

1
2
3
4
5
6
7
8
# Run a single command on a remote server
ssh user@server1 "uptime"

# Run multiple commands sequentially
ssh user@server1 "cd /opt/app && git pull && systemctl restart myapp"

# Run commands with sudo
ssh user@server1 "sudo systemctl status nginx"

Passing Environment Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pass variables into SSH session
DEPLOY_ENV="staging" APP_VERSION="v2.1.0" ssh user@server1 "echo \$DEPLOY_ENV \$APP_VERSION"

# Safer approach using heredoc
ssh user@server1 << 'REMOTE_SCRIPT'
    export DEPLOY_ENV="staging"
    cd /opt/app
    git pull origin main
    npm install --production
    systemctl restart myapp
REMOTE_SCRIPT

Handling Remote Execution Errors

1
2
3
4
5
6
7
# Check exit code
if ssh user@server1 "systemctl status nginx" 2>/dev/null; then
    echo "Nginx is running on server1"
else
    echo "Nginx is down on server1 — attempting restart"
    ssh user@server1 "sudo systemctl restart nginx"
fi

File Transfer Between Servers

scp — Secure Copy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Copy file from local to remote
scp /local/path/config.yml user@server1:/opt/app/config.yml

# Copy directory recursively
scp -r /local/path/dist/ user@server1:/opt/app/dist/

# Copy from remote to local
scp user@server1:/var/log/app.log /local/logs/

# Copy between two servers (via local intermediary)
scp user@server1:/data/backup.sql user@server2:/data/restore.sql

rsync — Remote Sync

rsync is more powerful than scp because it supports incremental synchronization—only transferring changed files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Sync dist directory to server
rsync -avz --progress /local/path/dist/ user@server1:/opt/app/dist/

# rsync over SSH with custom port
rsync -avz -e "ssh -p 2222" /local/path/ user@server1:/opt/app/

# Sync only files modified within the last day
rsync -avz --max-age=1 /local/path/logs/ user@server1:/backup/logs/

# Dry-run before executing
rsync -avzn --delete /local/path/ user@server1:/opt/app/

Flag explanation:

  • -a: Archive mode (preserves file permissions, symlinks, timestamps).
  • -v: Verbose—displays files being transferred.
  • -z: Compress data during network transfer.
  • --delete: Remove files at the destination that don’t exist in the source.
  • --progress: Display transfer progress.

Looping Through Multiple Servers

Basic Loop

1
2
3
4
5
6
SERVERS=("web-01" "web-02" "web-03")

for server in "${SERVERS[@]}"; do
    echo "=== $server ==="
    ssh "deploy@${server}" "hostname && uptime && df -h / | tail -1"
done

Reading Server List from a File

1
2
3
4
5
6
7
8
9
# servers.txt contains one server per line
# web-01
# web-02
# db-01

while IFS= read -r server; do
    [[ -z "$server" || "$server" =~ ^# ]] && continue
    ssh "deploy@${server}" "hostname" 2>/dev/null && echo "OK: $server" || echo "FAIL: $server"
done < servers.txt

Error Handling in Loops

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
SERVERS=("web-01" "web-02" "web-03")
FAILED=()

for server in "${SERVERS[@]}"; do
    if ! ssh "deploy@${server}" "echo OK" 2>/dev/null; then
        FAILED+=("$server")
        echo "[ERROR] Cannot connect to $server"
    fi
done

if [[ ${#FAILED[@]} -gt 0 ]]; then
    echo "Failed servers: ${FAILED[*]}"
    exit 1
fi

Parallel Execution with & and wait

When managing multiple servers, sequential execution can be extremely slow. Bash supports parallel execution using & (background process) and wait (wait for all to complete):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
SERVERS=("web-01" "web-02" "web-03" "db-01" "db-02")
MAX_PARALLEL=3

run_on_server() {
    local server="$1"
    echo "[START] $server"
    ssh "deploy@${server}" "sudo apt update && sudo apt upgrade -y" 2>/dev/null
    local status=$?
    echo "[DONE] $server (exit: $status)"
    return $status
}

# Run in parallel with concurrency limit
pids=()
for server in "${SERVERS[@]}"; do
    run_on_server "$server" &
    pids+=($!)

    # Limit concurrent processes
    if [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; then
        wait "${pids[0]}"
        pids=("${pids[@]:1}")
    fi
done

# Wait for all remaining processes
for pid in "${pids[@]}"; do
    wait "$pid"
done

echo "All servers updated."

Explanation:

  • run_on_server "$server" &: Run the function in the background.
  • pids+=($!): Save the PID of the background process.
  • wait "${pids[0]}": Wait for the first process to finish before starting a new one.
  • MAX_PARALLEL=3: Limit to a maximum of 3 concurrent processes.

Practical Example: Multi-Server Management Script

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env bash
# ==============================================================================
# Script : multi-server-deploy.sh
# Purpose: Deploy application to multiple servers in parallel
# ==============================================================================

set -euo pipefail
IFS=$'\n\t'

readonly SERVERS_FILE="${SERVERS_FILE:-/etc/deploy/servers.txt}"
readonly SSH_USER="${SSH_USER:-deploy}"
readonly SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519_deploy}"
readonly APP_DIR="${APP_DIR:-/opt/app}"
readonly MAX_PARALLEL="${MAX_PARALLEL:-5}"
readonly LOG_DIR="/var/log/multi-deploy"
readonly TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
readonly LOG_FILE="${LOG_DIR}/deploy_${TIMESTAMP}.log"

mkdir -p "$LOG_DIR"

log() {
    local level="$1"; shift
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] [$level] $*" | tee -a "$LOG_FILE"
}

load_servers() {
    if [[ ! -f "$SERVERS_FILE" ]]; then
        log "ERROR" "Servers file not found: $SERVERS_FILE"
        exit 1
    fi

    mapfile -t SERVERS < <(grep -vE '^\s*#|^\s*$' "$SERVERS_FILE")

    if [[ ${#SERVERS[@]} -eq 0 ]]; then
        log "ERROR" "No servers defined in $SERVERS_FILE"
        exit 1
    fi

    log "INFO" "Loaded ${#SERVERS[@]} servers from $SERVERS_FILE"
}

check_connectivity() {
    local server="$1"
    local identity_file="$SSH_KEY"

    if [[ ! -f "$identity_file" ]]; then
        log "ERROR" "SSH key not found: $identity_file"
        exit 1
    fi

    ssh -i "$identity_file" -o ConnectTimeout=10 -o BatchMode=yes \
        "${SSH_USER}@${server}" "echo OK" 2>/dev/null
}

deploy_to_server() {
    local server="$1"
    local deploy_log="${LOG_DIR}/deploy_${server}_${TIMESTAMP}.log"

    log "INFO" "Deploying to $server..."

    if ! check_connectivity "$server"; then
        log "ERROR" "Cannot connect to $server — skipping"
        return 1
    fi

    ssh -i "$SSH_KEY" "${SSH_USER}@${server}" << REMOTE > "$deploy_log" 2>&1
        set -euo pipefail

        echo "=== Deployment on \$(hostname) ==="
        echo "Time: \$(date)"

        cd "$APP_DIR" || { echo "Directory $APP_DIR not found"; exit 1; }

        echo "Pulling latest code..."
        git pull origin main

        echo "Installing dependencies..."
        npm install --production 2>/dev/null || pip install -r requirements.txt 2>/dev/null || true

        echo "Restarting service..."
        sudo systemctl restart myapp

        echo "Checking service status..."
        sleep 3
        if systemctl is-active --quiet myapp; then
            echo "Service myapp is running"
        else
            echo "ERROR: Service myapp failed to start"
            exit 1
        fi

        echo "=== Deployment completed ==="
REMOTE

    if [[ $? -eq 0 ]]; then
        log "INFO" "Deploy SUCCESS: $server"
    else
        log "ERROR" "Deploy FAILED: $server (check $deploy_log)"
        return 1
    fi
}

main() {
    log "INFO" "=== Multi-server deployment started ==="

    load_servers

    pids=()
    failed=()
    succeeded=0

    for server in "${SERVERS[@]}"; do
        deploy_to_server "$server" &
        pids+=($!)

        if [[ ${#pids[@]} -ge $MAX_PARALLEL ]]; then
            wait "${pids[0]}" || failed+=("${SERVERS[0]}")
            pids=("${pids[@]:1}")
        fi
    done

    for pid in "${pids[@]}"; do
        wait "$pid" || failed+=("server")
    done

    log "INFO" "=== Deployment completed ==="
    log "INFO" "Results: $(( ${#SERVERS[@]} - ${#failed[@]} ))/${#SERVERS[@]} succeeded"

    if [[ ${#failed[@]} -gt 0 ]]; then
        log "WARN" "Failed servers: ${failed[*]}"
        exit 1
    fi
}

main "$@"

servers.txt File Structure

1
2
3
4
5
6
7
8
# Staging servers
web-staging-01
web-staging-02

# Production servers
web-prod-01
web-prod-02
web-prod-03

Running the Script

1
2
chmod +x multi-server-deploy.sh
./multi-server-deploy.sh

Deployment Notes & Best Practices

  • Secure SSH keys: Set chmod 600 on private keys and chmod 644 on public keys. Never commit keys to a repository. In CI/CD, store keys as secrets and mount them as temporary files.
  • Host key verification: On the first connection, SSH prompts you to confirm the host key. In automated scripts, use StrictHostKeyChecking accept-new in ~/.ssh/config or the -o StrictHostKeyChecking=accept-new flag to auto-accept.
  • Concurrency limits: Running too many SSH sessions simultaneously can overload the network or source server. Always set MAX_PARALLEL appropriate for your infrastructure.
  • Connection timeouts: Use -o ConnectTimeout=10 and -o BatchMode=yes to prevent scripts from hanging indefinitely when a server is unresponsive.
  • Rollback strategy: Always have a rollback plan before bulk deployments. The example script above only deploys—in production, add a snapshot or backup step before making changes.

Conclusion

Automating SSH with Bash not only saves time but also ensures consistency when managing multiple servers. From setting up key-based authentication, configuring ~/.ssh/config for quick access, using scp/rsync for file transfers, to running commands in parallel with & and wait—all can be combined into a clean Bash script.

In the next post, we will explore combining Bash and Docker: writing wrapper scripts to manage containers, auto-restarting on crashes, and deploying stacks with docker compose.

RESPONSES & DISCUSSION