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:
- TCP handshake — three-way SYN/SYN-ACK/ACK (~1 RTT)
- SSH version exchange — client and server announce protocol versions
- Key exchange — Diffie-Hellman or Curve25519 negotiation to establish a shared secret (~1–2 RTTs)
- Host key verification — server proves its identity
- User authentication — key signing challenge/response (~1 RTT)
- 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.
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
Create the sockets directory:
mkdir -p ~/.ssh/sockets
chmod 700 ~/.ssh/sockets
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
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)
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
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
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
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
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"
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
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
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
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
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
%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
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
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
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
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
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
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
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)