Learning Goals
3 minBy the end of this lesson you can:
- Connect to a server over SSH with
paramiko, using key-based auth. - Run a remote command and read its
stdout,stderr, and exit code. - Handle host keys safely (and understand why blindly trusting them is risky).
- Use a context manager so connections always close.
Warm-Up · SSH, From Python
5 minYou've probably typed ssh user@server and then run commands. paramiko does exactly that programmatically: open a secure connection, send a command, get the output back as strings — so you can manage many servers in a loop, or fold remote steps into a pipeline.
pip install paramiko
SSH gives you an encrypted channel and an identity. Authenticate with an SSH key (not a password in code), run a command remotely, and read its stdout/stderr/exit-status just like subprocess (Lesson 10) — but on a machine across the world. The discipline: keys not passwords, verify host keys, and always close the connection.
New Concept · Connect & Execute
14 minConnecting with a key
import paramiko, os client = paramiko.SSHClient() client.load_system_host_keys() # trust hosts you've connected to before client.set_missing_host_key_policy(paramiko.RejectPolicy()) # refuse unknowns client.connect( hostname=os.environ["SSH_HOST"], username=os.environ["SSH_USER"], key_filename=os.environ["SSH_KEY"], # path to your private key timeout=15, ) # …use it… client.close()
- Key-based auth (
key_filename) is the norm — far safer than passwords and the only thing you should automate with. - Host/user/key paths come from the environment (Lesson 8), never hard-coded.
- Always
close()— or use a context manager (below).
Running a command
stdin, stdout, stderr = client.exec_command("df -h /") exit_code = stdout.channel.recv_exit_status() # waits for completion out = stdout.read().decode() err = stderr.read().decode() print("exit:", exit_code) print(out) if err: print("stderr:", err)
exec_command returns three streams. Read the exit status via stdout.channel.recv_exit_status() — it also blocks until the command finishes. Then decode the bytes to strings. It's the remote twin of Lesson 10's capture_output.
Host-key safety
You'll see set_missing_host_key_policy(paramiko.AutoAddPolicy()) everywhere online. It accepts any host key without checking — convenient, but it defeats SSH's protection against man-in-the-middle attacks (you could be talking to an impostor). For real servers, pre-load known host keys (load_system_host_keys / load_host_keys) and use RejectPolicy. AutoAdd is acceptable only for throwaway lab boxes you control.
A safe context-manager wrapper
from contextlib import contextmanager import paramiko, os @contextmanager def ssh_connection(): client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.RejectPolicy()) try: client.connect(hostname=os.environ["SSH_HOST"], username=os.environ["SSH_USER"], key_filename=os.environ["SSH_KEY"], timeout=15) yield client finally: client.close() # always closes, even on error def run_remote(client, command: str) -> tuple[int, str, str]: _, out, err = client.exec_command(command) code = out.channel.recv_exit_status() return code, out.read().decode(), err.read().decode() with ssh_connection() as ssh: code, out, err = run_remote(ssh, "uptime") print(out.strip())
The context manager guarantees the connection closes; run_remote gives you a clean (code, out, err) tuple per command. This pair is the foundation for everything remote.
Worked Example · A Remote Health Snapshot
12 minGoal: SSH into a server, run several diagnostic commands, parse their output, and return a tidy health dict — the start of a server monitor.
import os, logging from contextlib import contextmanager import paramiko logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") log = logging.getLogger("ssh") @contextmanager def ssh_connection(host: str): client = paramiko.SSHClient() client.load_system_host_keys() client.set_missing_host_key_policy(paramiko.RejectPolicy()) try: client.connect(hostname=host, username=os.environ["SSH_USER"], key_filename=os.environ["SSH_KEY"], timeout=15) yield client finally: client.close() def run(client, cmd: str) -> tuple[int, str, str]: _, out, err = client.exec_command(cmd, timeout=30) return out.channel.recv_exit_status(), out.read().decode(), err.read().decode() def health(host: str) -> dict: info = {"host": host} try: with ssh_connection(host) as ssh: _, uptime, _ = run(ssh, "uptime -p") info["uptime"] = uptime.strip() code, disk, _ = run(ssh, "df -h / | tail -1 | awk '{print $5}'") info["disk_used"] = disk.strip() _, mem, _ = run(ssh, "free -m | awk '/Mem:/ {printf \"%d%%\", $3/$2*100}'") info["mem_used"] = mem.strip() _, who, _ = run(ssh, "who | wc -l") info["users"] = who.strip() info["reachable"] = True except Exception as e: log.error("cannot reach %s: %s", host, e) info["reachable"] = False return info for h in os.environ.get("SSH_HOSTS", "").split(","): if h: print(health(h))
{'host': 'web-01', 'uptime': 'up 12 days, 4 hours',
'disk_used': '63%', 'mem_used': '41%', 'users': '2', 'reachable': True}
ERROR cannot reach db-02: timed out
{'host': 'db-02', 'reachable': False}Read the code
The context manager ensures each connection closes even if a command fails, and the whole per-host block is wrapped so an unreachable server is recorded as reachable: False rather than crashing the loop — so one dead box doesn't stop you checking the rest. We run standard Unix one-liners remotely and parse their stdout into a dict, exactly like capturing local subprocess output. Loop over a list of hosts and you have a fleet health snapshot — which Lesson 43 turns into a full monitor.
Try It Yourself
13 minNo server? Spin up a free tier VM, use a Raspberry Pi, or run an SSH server locally (you can ssh localhost on most systems after enabling it). Generate a key with ssh-keygen and add the public key to the server's authorized_keys.
Connect with a key and run whoami and hostname. Print the output and the exit code. Confirm the connection closes.
Run a command that succeeds (ls /) and one that fails (ls /nonexistent). Confirm you get exit 0 vs. non-zero and that stderr contains the error.
Hint
for cmd in ["ls /", "ls /nope"]: code, out, err = run_remote(ssh, cmd) print(cmd, "→ exit", code, "|", (err or out).strip()[:40])
Write run_on_all(hosts, command) that runs the same command on each host and returns a dict of host → (exit, output), marking unreachable hosts. Test with at least one good and one bad host.
Hint
def run_on_all(hosts, command): results = {} for host in hosts: try: with ssh_connection(host) as ssh: results[host] = run_remote(ssh, command)[:2] except Exception as e: results[host] = ("unreachable", str(e)) return results
Mini-Challenge · The Remote Runbook
8 minWrite run_steps(host, steps) that executes a list of commands in order on a host, stopping at the first non-zero exit and reporting which step failed (with its stderr). This is a mini deployment runbook — and the seed of the orchestrator in Lesson 47.
Show a sample solution
def run_steps(host: str, steps: list[str]) -> bool: with ssh_connection(host) as ssh: for i, cmd in enumerate(steps, start=1): code, out, err = run_remote(ssh, cmd) if code != 0: log.error("step %d failed: %s\n%s", i, cmd, err.strip()) return False log.info("step %d ok: %s", i, cmd) log.info("all %d steps succeeded", len(steps)) return True run_steps("web-01", [ "cd /app && git pull", "cd /app && pip install -r requirements.txt", "sudo systemctl restart myapp", "curl -fsS http://localhost/health", ])
Non-negotiables: ordered execution, stop-on-first-failure, reports the failing step + stderr.
Recap
3 minparamiko SSHes into a server from Python: connect with key-based auth (paths from the environment), run a command with exec_command, and read its stdout/stderr plus the exit status via recv_exit_status() — the remote equivalent of Lesson 10's subprocess capture. Wrap connections in a context manager so they always close, and handle host keys responsibly: pre-load known hosts and reject unknowns rather than blindly AutoAddPolicy. Loop over hosts to manage a fleet. Next: moving files over the same connection with SFTP.
Vocabulary Card
- SSH
- An encrypted protocol for logging into and running commands on remote machines.
- key-based auth
- Authenticating with a private key file instead of a password.
- exec_command
- paramiko's way to run a remote command and get its streams.
- host key
- A server's identity fingerprint; verifying it prevents impersonation.
Homework
4 minBuild remote.py: a small module with a ssh_connection context manager, a run_remote helper returning (code, out, err), and a run_steps runbook function — all key-based, env-configured, with host-key safety. Test it against a VM, Pi, or localhost SSH. Write two sentences on why you used key auth and a non-AutoAdd host policy.
Sample · remote.py rationale
remote.py provides: ssh_connection(host) — context manager, always closes run_remote(ssh, cmd) — returns (exit_code, stdout, stderr) run_steps(host, steps)— ordered runbook, stop on first failure Why key auth: passwords in scripts leak and can be brute-forced; SSH keys can't be typed/guessed, can be passphrase-protected, and are revocable per-machine. Why not AutoAddPolicy: it trusts any host key, so a hijacked DNS / MITM could impersonate the server and capture everything I run. RejectPolicy + known_hosts ensures I only ever talk to the real server.
Non-negotiables: context manager, (code,out,err) helper, runbook, key auth, host-key safety, a tested target.