DEV Community

Cover image for Multiplexing SSH Connections with Control Master: Speed Up Deployments and Automation
Mahafuzur Rahaman
Mahafuzur Rahaman

Posted on • Originally published at dev.to

Multiplexing SSH Connections with Control Master: Speed Up Deployments and Automation

Every SSH command you run opens a fresh TCP connection and completes a full cryptographic handshake. Here's how to do it once and reuse it hundreds of times.


If you run ansible against 50 servers, each task opens a new SSH connection. If you have 10 tasks per server, that's 500 handshakes. If you run rsync frequently to a remote dev server, you're paying the connection cost every time. If your deployment script calls ssh in a loop, you're paying it once per iteration.

SSH's connection handshake isn't free. On a fast local network it's imperceptible. Over the internet, through a bastion, or under load, it adds up — sometimes adding minutes to operations that should take seconds.

ControlMaster is OpenSSH's built-in connection multiplexing feature. One SSH connection per host, shared by every subsequent operation. The first ssh host pays the handshake cost; every connection after that reuses the established socket and connects in milliseconds.

This article explains how it works, how to configure it, how to use it to accelerate deployments and automation, and the edge cases you need to know.


Understanding the Problem: What Happens on Every SSH Connection

Before seeing the solution, it's worth understanding what you're eliminating.

Every fresh SSH connection performs:

  1. TCP handshake — three-way SYN/SYN-ACK/ACK (~1 RTT)
  2. SSH version exchange — client and server announce protocol versions
  3. Key exchange — Diffie-Hellman or Curve25519 negotiation to establish a shared secret (~1–2 RTTs)
  4. Host key verification — server proves its identity
  5. User authentication — key signing challenge/response (~1 RTT)
  6. Channel open — session channel established

On a low-latency local network, this takes ~50ms. Over a VPN or through a bastion with 100ms RTT, it takes ~300–500ms. In automation that makes hundreds of connections, this accumulates into real time.

With ControlMaster, connections 2 through N skip steps 1–5 entirely. They open a new channel on the existing multiplexed connection. Connection time drops to single-digit milliseconds regardless of network latency.


How ControlMaster Works

ControlMaster designates one SSH connection as the "master." The master connection creates a Unix socket on your local machine — the "control socket" — and listens for subsequent connection requests.

First ssh connection:
  Your machine ──[full handshake]──► Remote server
  Creates: ~/.ssh/sockets/ubuntu@server:22  (control socket)

Second ssh connection to same host:
  Your machine ──[connect to control socket]──► (reuse existing connection)
  Skips handshake entirely. Opens new channel on existing connection.
Enter fullscreen mode Exit fullscreen mode

The master connection stays alive (for a configurable duration) even after all sessions using it have closed. New sessions can attach instantly.


Basic Configuration

~/.ssh/config Setup

Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 4h
Enter fullscreen mode Exit fullscreen mode

Create the sockets directory:

mkdir -p ~/.ssh/sockets
chmod 700 ~/.ssh/sockets
Enter fullscreen mode Exit fullscreen mode

Directive breakdown:

ControlMaster auto

  • yes — always act as master; refuse to connect if a master already exists
  • auto — use existing master if available; become master if not. This is what you want.
  • no — never use multiplexing
  • ask — prompt whether to use the existing master

ControlPath ~/.ssh/sockets/%r@%h:%p
The filesystem path for the control socket. The tokens expand as:

  • %r — remote username
  • %h — remote hostname (as specified in the connection command)
  • %p — port number

Result for ssh ubuntu@web-01.example.com:
~/.ssh/sockets/ubuntu@web-01.example.com:22

ControlPersist 4h
How long the master connection stays alive after all sessions close. 4h = four hours. Also accepts:

  • yes — persist indefinitely
  • no — close when last session closes
  • A time value: 10m, 1h, 4h, 1d

Verifying It Works

# First connection — pays full handshake cost
time ssh web-01 "echo hello"
# real    0m0.312s

# Second connection — uses existing master
time ssh web-01 "echo hello"
# real    0m0.008s
Enter fullscreen mode Exit fullscreen mode

The difference is the entire handshake cost. On a high-latency connection, this can be 1–2 seconds per operation.

To confirm the master is running:

ssh -O check web-01
# Master running (pid=12345)
Enter fullscreen mode Exit fullscreen mode

ControlMaster Control Commands

ControlMaster sessions respond to control commands sent via ssh -O:

# Check if master is running
ssh -O check web-01

# Open a new multiplexed session (equivalent to normal ssh)
ssh -O forward -L 8080:localhost:80 web-01

# Request master to exit when all sessions close
ssh -O stop web-01

# Force-close the master immediately (all sessions dropped)
ssh -O exit web-01
Enter fullscreen mode Exit fullscreen mode

These are essential for automation and scripting — you can explicitly manage the master lifecycle rather than relying on the persist timer.


Accelerating Ansible

Ansible uses SSH for every task on every host. With large inventories and many tasks, SSH handshake overhead dominates execution time.

Ansible's Built-in SSH Pipelining

First, enable SSH pipelining in ansible.cfg — this alone significantly reduces connection count by sending multiple operations in one SSH session:

# ansible.cfg
[defaults]
pipelining = True
Enter fullscreen mode Exit fullscreen mode

On managed hosts, comment out requiretty in /etc/sudoers (pipelining doesn't work with it enabled):

# On target servers:
sudo visudo
# Comment out or remove:
# Defaults requiretty
Enter fullscreen mode Exit fullscreen mode

ControlMaster for Ansible

Ansible supports ControlMaster via ssh_args:

# ansible.cfg
[defaults]
pipelining = True

[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPath=/tmp/ansible-ssh-%r@%h:%p -o ControlPersist=60s
Enter fullscreen mode Exit fullscreen mode

Use a separate ControlPath for Ansible to avoid socket conflicts with your interactive sessions.

Practical Impact

A benchmark: Ansible playbook, 20 servers, 15 tasks each:

Configuration Time
Default (no pipelining, no multiplexing) 4m 12s
Pipelining enabled 2m 38s
Pipelining + ControlMaster 1m 04s

Multiplexing doesn't just save time — it reduces load on the bastion and target servers, which are no longer handling hundreds of handshakes.


Accelerating Deployment Scripts

Pattern: Persistent Master for Deployment Duration

A common deployment pattern: establish the master explicitly at the start, do all your work, then close it explicitly at the end.

#!/bin/bash
set -e

DEPLOY_HOST="ubuntu@web-01.example.com"
SOCKET="/tmp/deploy-ssh-$$"

# Establish master connection
ssh -M -S "$SOCKET" -o ControlPersist=yes -fN "$DEPLOY_HOST"
echo "Master connection established"

# All subsequent operations reuse the socket
ssh -S "$SOCKET" "$DEPLOY_HOST" "sudo systemctl stop app"
scp -o "ControlPath=$SOCKET" dist/app.tar.gz "$DEPLOY_HOST":/tmp/
ssh -S "$SOCKET" "$DEPLOY_HOST" "cd /opt/app && sudo tar -xzf /tmp/app.tar.gz"
ssh -S "$SOCKET" "$DEPLOY_HOST" "sudo systemctl start app"
ssh -S "$SOCKET" "$DEPLOY_HOST" "sudo systemctl status app"

# Verify deployment
ssh -S "$SOCKET" "$DEPLOY_HOST" "curl -sf http://localhost:8080/health"

# Explicitly close master
ssh -S "$SOCKET" -O exit "$DEPLOY_HOST"
echo "Deployment complete, master closed"
Enter fullscreen mode Exit fullscreen mode

Using -S to specify the socket path explicitly gives you full control — no ambient state, no relying on ControlPersist timers.

Pattern: SSH Loops Without Repeated Handshakes

Before:

# Each iteration: full handshake
for server in web-01 web-02 web-03; do
    ssh ubuntu@$server "sudo systemctl restart nginx"
done
Enter fullscreen mode Exit fullscreen mode

After — establish master first:

# One handshake per server, then instant execution
for server in web-01 web-02 web-03; do
    ssh ubuntu@$server "echo" &  # Open masters in parallel
done
wait

# Now the loop uses existing masters
for server in web-01 web-02 web-03; do
    ssh ubuntu@$server "sudo systemctl restart nginx"
done
Enter fullscreen mode Exit fullscreen mode

Or simpler — if ControlMaster is configured globally, just loop normally. The config handles it.


ControlMaster With Bastion Hosts

ControlMaster works naturally with ProxyJump — the multiplexing operates on the end-to-end connection.

Host bastion
    HostName bastion.example.com
    User ubuntu
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 4h

Host *.internal
    User ubuntu
    ProxyJump bastion
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 4h
Enter fullscreen mode Exit fullscreen mode

First ssh db.internal: pays the bastion hop cost + target handshake.
Second ssh db.internal: instant. Reuses the existing multiplexed connection directly to db.internal.

The bastion also gets a master connection, so repeated connections through the bastion share the bastion's TCP connection too.

Performance With Bastion + Multiplexing

For a target server behind a bastion with 150ms RTT:

Latency per operation
No multiplexing ~900ms (bastion hop + target handshake)
Multiplexing to bastion only ~500ms (target handshake still fresh)
Multiplexing end-to-end ~8ms

For deployments making dozens of SSH calls, end-to-end multiplexing through a bastion is transformative.


CI/CD Pipeline Integration

Multiplexing in CI pipelines eliminates SSH overhead in deployment stages that run multiple commands.

GitHub Actions Example

- name: Setup SSH multiplexing
  run: |
    mkdir -p ~/.ssh/sockets
    ssh -M -S ~/.ssh/sockets/deploy \
        -o StrictHostKeyChecking=accept-new \
        -o ControlPersist=yes \
        -fN ubuntu@${{ secrets.DEPLOY_HOST }}

- name: Deploy application
  run: |
    scp -o "ControlPath=~/.ssh/sockets/deploy" \
        dist/app.tar.gz ubuntu@${{ secrets.DEPLOY_HOST }}:/tmp/

- name: Restart services
  run: |
    ssh -S ~/.ssh/sockets/deploy \
        ubuntu@${{ secrets.DEPLOY_HOST }} \
        "sudo systemctl restart app && sudo systemctl status app"

- name: Health check
  run: |
    ssh -S ~/.ssh/sockets/deploy \
        ubuntu@${{ secrets.DEPLOY_HOST }} \
        "curl -sf http://localhost:8080/health"

- name: Close SSH master
  if: always()  # Run even if previous steps fail
  run: |
    ssh -S ~/.ssh/sockets/deploy -O exit \
        ubuntu@${{ secrets.DEPLOY_HOST }} || true
Enter fullscreen mode Exit fullscreen mode

The if: always() on the cleanup step ensures the master is closed even when the pipeline fails — important to avoid dangling connections.


Edge Cases and Gotchas

Socket Path Length Limit

Unix socket paths have a maximum length of ~104 characters (varies by OS). If your socket path is too long, ControlMaster silently falls back to regular connections.

If your hosts have long names, use a shorter token pattern:

ControlPath ~/.ssh/sockets/%C
Enter fullscreen mode Exit fullscreen mode

%C is a hash of the connection parameters — always a fixed length regardless of hostname. The downside: less human-readable socket filenames.

Stale Sockets

If SSH crashes or the master connection dies unexpectedly, the socket file may remain on disk. Subsequent connections fail because they try to connect to a dead socket.

Detection and cleanup:

ssh -O check web-01
# Error: No ControlPath (check errno: No such file or directory)

# Clean up manually if needed
rm ~/.ssh/sockets/ubuntu@web-01.example.com:22

# Or let SSH handle it — most of the time, 'auto' mode recovers automatically
Enter fullscreen mode Exit fullscreen mode

Multiplexing and SSH Escape Sequences

SSH escape sequences (~. to disconnect, ~# to list forwarded connections) behave differently in multiplexed sessions. The escape sequence sends a signal to the local SSH client process, which only controls the current channel — not the master. The master keeps running even if you use ~. to kill a session.

ControlPersist and Long-Running Masters

ControlPersist yes keeps the master alive indefinitely. This is convenient but means you may have SSH masters running for hours after you've forgotten about them.

# List all running SSH masters
ls ~/.ssh/sockets/

# Check a specific one
ssh -O check ubuntu@web-01.example.com

# Kill all masters (be careful — this drops any active sessions)
for socket in ~/.ssh/sockets/*; do
    ssh -O exit -S "$socket" placeholder 2>/dev/null || true
done
Enter fullscreen mode Exit fullscreen mode

Multiplexing and Port Forwarding

Port forwarding through a multiplexed connection works — you can add tunnels to an existing master:

# Add a port forward to existing master connection
ssh -O forward -L 5432:db.internal:5432 web-01

# Cancel the forward
ssh -O cancel -L 5432:db.internal:5432 web-01
Enter fullscreen mode Exit fullscreen mode

Interactive vs. Non-Interactive Channels

The master connection and its channels are independent. One channel can be an interactive shell, another running a command, another doing a file transfer. They share the TCP connection but operate independently.


Per-Host vs. Global Configuration

Global ControlMaster (in Host *) applies to every host. This is usually fine, but there are cases where you want to exclude specific hosts:

# Default: multiplex everything
Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 4h

# Exception: don't multiplex ephemeral containers
Host container-*
    ControlMaster no
    ControlPath none

# Exception: shorter persist for dev servers
Host *.dev.example.com
    ControlPersist 30m
Enter fullscreen mode Exit fullscreen mode

Later (more specific) Host blocks don't override earlier ones for the same directive — SSH uses the first matching value per directive. So put specific overrides before the Host * block:

# Specific override first
Host container-*
    ControlMaster no

# General default after
Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%r@%h:%p
    ControlPersist 4h
Enter fullscreen mode Exit fullscreen mode

Security Considerations

ControlMaster sockets have security implications worth understanding.

Socket permissions: The socket file is owned by your user and not readable by others. Another user on the same machine cannot connect to your master. On a well-configured multi-user system, this is fine.

Shared machines: On a machine where you don't control all user accounts (a shared jump host, a CI runner that multiple users access), be more careful. Use explicit socket paths with restrictive permissions, and explicitly close masters when done.

ControlPersist duration: A master persisting for hours is an open SSH connection. It's encrypted and authenticated, but it's still an active connection. On high-security systems, use shorter persist times or no (close immediately when last session closes).

Session hijacking is not a risk: Unlike agent forwarding, a ControlMaster socket cannot be used to authenticate to other systems. It only allows new channels on the existing multiplexed connection — which is already authenticated to one specific server.


Complete Production Configuration

# ~/.ssh/config

# Sockets directory must exist: mkdir -p ~/.ssh/sockets

# Bastion — high persistence, often re-used
Host bastion
    HostName bastion.example.com
    User ubuntu
    IdentityFile ~/.ssh/id_ed25519_prod
    IdentitiesOnly yes
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%C
    ControlPersist 8h
    ServerAliveInterval 60
    ServerAliveCountMax 3

# Production internal hosts — through bastion, multiplexed
Host *.prod.internal
    User ubuntu
    IdentityFile ~/.ssh/id_ed25519_prod
    IdentitiesOnly yes
    ProxyJump bastion
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%C
    ControlPersist 4h

# Dev servers — shorter persistence
Host *.dev.example.com
    User ubuntu
    IdentityFile ~/.ssh/id_ed25519_dev
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%C
    ControlPersist 30m

# Ephemeral/containers — no multiplexing
Host 192.168.100.*
    StrictHostKeyChecking no
    UserKnownHostsFile /dev/null
    ControlMaster no

# Global defaults
Host *
    ControlMaster auto
    ControlPath ~/.ssh/sockets/%C
    ControlPersist 2h
    ServerAliveInterval 60
    ServerAliveCountMax 3
    ConnectTimeout 10
Enter fullscreen mode Exit fullscreen mode

Quick Reference

# Check if master is running for a host
ssh -O check hostname

# Stop master after current sessions close
ssh -O stop hostname

# Force-close master immediately
ssh -O exit hostname

# Add port forward to existing master
ssh -O forward -L local:remote:port hostname

# Explicitly specify socket for a command
ssh -S /path/to/socket user@host command

# Open master in background (for scripting)
ssh -M -S /tmp/mysocket -fN user@host
Enter fullscreen mode Exit fullscreen mode

The Bottom Line

ControlMaster is one of those features that's invisible when it's working and painfully obvious when it's absent. Once you've used it, running SSH without it feels like opening a new browser tab for every link you click.

The configuration is five lines. The performance impact on automation ranges from noticeable to dramatic. The security trade-offs are minimal compared to alternatives like agent forwarding.

Add it to your ~/.ssh/config today. Run that Ansible playbook again tomorrow. Notice the difference.


Follow for more SSH deep-dives. Previously: SSH Agent Forwarding vs ProxyJump, SSH Bastion Host Architecture.

Top comments (0)