Guide

SSH fundamentals explained

SSH (Secure Shell) is the encrypted protocol behind almost every remote login, file transfer, and deploy pipeline on Linux servers. It replaces cleartext telnet with authenticated, integrity-protected channels over TCP port 22 (or a custom port you choose). Whether you are opening a shell on a VPS, copying build artifacts with rsync, tunneling into a private database, or wiring CI to a staging host, SSH is the transport. This guide covers how the protocol layers work, Ed25519 key generation and permissions, ~/.ssh/config aliases, ssh-agent and forwarding risks, jump hosts with ProxyJump, local and remote port forwarding, sshd_config hardening, a Harbor Fleet multi-server worked example, an authentication decision table, common pitfalls, and a production checklist. For broader server operations, see Linux fundamentals explained; for encrypting web traffic separately from SSH, see TLS and HTTPS explained.

How SSH is structured

SSH is not a single command — it is a stack of protocols negotiated when client and server connect. The transport layer establishes encryption, server authentication (host key fingerprint), and integrity checks. The user authentication layer verifies who you are — public-key, password, keyboard-interactive, or certificate. The connection layer multiplexes channels: an interactive shell, a remote command, sftp file transfer, or a forwarded TCP port each ride on separate channels inside one encrypted session.

On first connect, your client stores the server’s host key fingerprint in ~/.ssh/known_hosts. If the fingerprint changes later (rebuilt VM, MITM attack, load balancer misconfiguration), SSH warns you loudly — never blindly accept a changed host key without verifying why it changed. Cloud providers often publish expected fingerprints in their docs or console.

SSH vs TLS — complementary, not interchangeable

TLS terminates HTTPS for browsers and public APIs. SSH secures operator and automation access to machines. A typical VPS runs both: nginx terminates TLS on 443 for users, while administrators reach the box only via SSH on 22 (or a non-standard port behind a firewall). Application secrets belong in secrets managers, not in SSH config files committed to git.

Key-based authentication

Password authentication is convenient for first bootstrapping but scales poorly: passwords leak, get reused, and invite brute-force bots scanning port 22. Public-key authentication is the production default. You generate a key pair: a private key (never leaves your machine) and a public key (safe to copy to servers).

Generate an Ed25519 key pair

Prefer Ed25519 in 2026 — shorter keys, faster operations, and no legacy RSA padding pitfalls. Generate with a comment that identifies the key’s purpose:

ssh-keygen -t ed25519 -C "harbor-deploy@laptop" -f ~/.ssh/harbor_ed25519

Set a passphrase on the private key. The passphrase encrypts the key file at rest; ssh-agent caches the decrypted key in memory during your session so you are not prompted on every connection.

Install the public key on a server

Append the public key to ~/.ssh/authorized_keys on the remote account. One line per key. Permissions matter — SSH refuses keys if directories are world-writable:

  • chmod 700 ~/.ssh
  • chmod 600 ~/.ssh/authorized_keys
  • chmod 600 ~/.ssh/harbor_ed25519 (private key on client)

Use ssh-copy-id -i ~/.ssh/harbor_ed25519.pub deploy@staging.example for the initial install, then disable password auth once you confirm key login works from a second terminal (so you do not lock yourself out).

ssh-agent and key forwarding

ssh-agent holds decrypted private keys. Start it, add your key with ssh-add ~/.ssh/harbor_ed25519, and subsequent ssh / git / rsync calls use the agent automatically. Agent forwarding (ForwardAgent yes) lets a remote host use your local keys to reach a third machine — convenient for jump-host workflows but dangerous if the remote server is compromised (attackers can sign with your keys). Prefer ProxyJump (see below) over agent forwarding when possible.

~/.ssh/config — stop retyping flags

A client config file turns long commands into short aliases. Example for a Harbor staging API host:

Host harbor-staging
  HostName staging.api.harbor.example
  User deploy
  IdentityFile ~/.ssh/harbor_ed25519
  IdentitiesOnly yes
  ServerAliveInterval 60

Now ssh harbor-staging and rsync -avz ./dist/ harbor-staging:/var/www/harbor/ just work. Useful directives:

  • IdentitiesOnly yes — do not offer every key in the agent; pick the right one.
  • ServerAliveInterval — keep NAT firewalls from dropping idle sessions.
  • LocalForward 5433 localhost:5432 — tunnel local port 5433 to remote Postgres (see forwarding section).
  • StrictHostKeyChecking accept-new — auto-add new hosts but still warn on changes (OpenSSH 7.6+).

File transfer: scp, sftp, and rsync

SSH is not only interactive shells. scp copies files (simple, legacy). sftp provides an interactive file browser. rsync is the deploy workhorse — incremental sync, compression, and delete flags for mirror deploys:

rsync -avz --delete ./public/ harbor-staging:/var/www/harbor/

Pair rsync with Bash deploy scripts and set -euo pipefail so a failed sync aborts the pipeline. For large artifacts, consider pushing to object storage and pulling on the server instead of streaming gigabytes over SSH.

Jump hosts and bastions

Production networks often hide app servers on private subnets. A bastion (jump host) is the only machine with a public IP; you SSH to the bastion, then SSH again to internal hosts. Modern OpenSSH collapses two hops into one with ProxyJump:

Host harbor-db-internal
  HostName 10.0.2.15
  User deploy
  ProxyJump harbor-bastion
  IdentityFile ~/.ssh/harbor_ed25519

The client opens an encrypted tunnel through the bastion automatically. Lock the bastion down tightly: key-only auth, minimal users, audit logging, and no application workloads running on it. Internal hosts should accept connections only from the bastion’s security group, not from the open internet.

Port forwarding

Local forward — reach a remote service from your laptop

ssh -L 5433:localhost:5432 harbor-staging binds your laptop’s port 5433 to Postgres on the remote localhost:5432. Your local GUI client connects to 127.0.0.1:5433 as if the database were local. Essential for debugging production data without exposing Postgres to the public internet.

Remote forward — expose your laptop to the server

ssh -R 8080:localhost:3000 harbor-staging makes your local dev server reachable on the remote host’s port 8080. Useful for webhook testing; risky if GatewayPorts is enabled — keep remote forwards disabled in sshd_config unless you explicitly need them.

Dynamic forward — SOCKS proxy

ssh -D 1080 harbor-bastion creates a SOCKS5 proxy through the SSH connection. Browser or CLI tools route traffic through the tunnel to reach resources only visible from inside the VPC.

Hardening sshd_config

Server settings live in /etc/ssh/sshd_config (validate with sshd -t before restart). Baseline hardening for internet-facing hosts:

  • PasswordAuthentication no — keys only after bootstrap.
  • PermitRootLogin prohibit-password or no — never root with a password.
  • PubkeyAuthentication yes
  • MaxAuthTries 3 — limit brute-force attempts per connection.
  • AllowUsers deploy or AllowGroups ssh-users — restrict who can log in.
  • X11Forwarding no — disable unless you need GUI apps.
  • AllowTcpForwarding no on bastions if users should not tunnel further (trade-off with ops needs).

Change the default port (Port 2222) only as security through obscurity — it cuts noise in logs but does not replace keys and firewalls. Pair with ufw or cloud security groups allowing SSH only from office IPs or a VPN. Install fail2ban to ban IPs after repeated failures.

After editing, restart with systemctl reload ssh (or sshd on some distros) — keep a second session open while testing so a bad config does not lock you out.

Worked example: Harbor Fleet three-server deploy

Harbor Fleet runs an API, a worker, and a read replica database across three Ubuntu VPS instances. Operators never expose Postgres or the worker queue to the public internet.

  1. Bastionbastion.harbor.example, UFW allows 22 only from the office IP range. User deploy, Ed25519 keys, password auth off.
  2. App tierapi-01 and worker-01 on 10.0.1.0/24, reachable only via bastion. nginx on api-01 proxies to Node on 127.0.0.1:3000.
  3. Data tierdb-01 on 10.0.2.15, Postgres listens on private interface only.
  4. Client configProxyJump entries for harbor-api, harbor-worker, harbor-db; LocalForward 5433 10.0.2.15:5432 on the db host alias for DBA access.
  5. Deploy — CI job rsyncs build artifacts to harbor-api and harbor-worker over SSH; systemctl restart harbor-api via a forced command or short-lived deploy key with command restriction.
  6. Verifyssh harbor-api 'curl -sf localhost:3000/healthz'; check journalctl -u harbor-api -n 20 on failure.

Result: public users hit HTTPS on the API; operators and CI reach internals through encrypted SSH without opening database ports to the world.

Authentication and access decision table

ScenarioRecommended approachAvoid
Human daily opsEd25519 key + passphrase + ssh-agentShared root password in a wiki
CI/CD deployDedicated deploy key per repo/host, forced command or short TTLPersonal laptop key on the CI runner
First server bootstrapPassword or cloud console once, then keys + disable passwordLeaving password auth on indefinitely
Private DB accessLocalForward through bastionPublic Postgres port 5432 open
Multi-hop internal hostsProxyJump in ~/.ssh/configAgent forwarding to every hop
Large teamSSH certificates (Vault, step-ca) with short TTLManually copying 50 keys to authorized_keys
Legacy RSA-only systems4096-bit RSA temporarily, plan migration1024-bit RSA or DSA keys

Common pitfalls

  • Wrong permissions on ~/.ssh — SSH silently ignores keys if group/other can write the directory or private key.
  • Disabling password auth before confirming key login — always test a second session before closing your only working connection.
  • Reusing one private key everywhere — compromise of CI leaks production; use per-environment keys.
  • Agent forwarding on untrusted hosts — remote attackers can use your agent to pivot.
  • Ignoring host key change warnings — could be MITM or a rebuilt server; verify out-of-band before accepting.
  • Committing private keys to git — scanners find them in minutes; rotate immediately if leaked.
  • No backup access path — cloud serial console or IPMI saves you when sshd_config is broken.
  • Tunneling around security policy — local forwards can bypass corporate firewalls; know your org rules.

Practitioner checklist

  • Ed25519 keys with passphrases; IdentitiesOnly yes in config.
  • Password and root login disabled on internet-facing sshd.
  • Firewall restricts SSH source IPs; fail2ban or equivalent enabled.
  • ~/.ssh permissions 700; private keys 600; authorized_keys 600.
  • ~/.ssh/config aliases for every host you touch regularly.
  • Deploy keys scoped per environment; personal keys never on shared CI.
  • Bastion + private subnets for multi-tier apps; no public database ports.
  • Document serial console or cloud recovery access before changing SSH.
  • Automated security updates for OpenSSH packages.
  • Audit auth.log or journal for repeated failed attempts.

Key takeaways

  • SSH encrypts shells, file transfers, and TCP tunnels — it is operator infrastructure, not user-facing HTTPS.
  • Ed25519 public-key auth with disabled passwords is the baseline for production servers.
  • ~/.ssh/config and ProxyJump turn multi-hop access into one command.
  • Local port forwarding safely reaches private databases without exposing them to the internet.
  • Harden sshd_config, restrict firewall sources, and never lock yourself out without a recovery path.

Related reading