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 ~/.sshchmod 600 ~/.ssh/authorized_keyschmod 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-passwordorno— never root with a password.PubkeyAuthentication yesMaxAuthTries 3— limit brute-force attempts per connection.AllowUsers deployorAllowGroups ssh-users— restrict who can log in.X11Forwarding no— disable unless you need GUI apps.AllowTcpForwarding noon 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.
- Bastion —
bastion.harbor.example, UFW allows 22 only from the office IP range. Userdeploy, Ed25519 keys, password auth off. - App tier —
api-01andworker-01on10.0.1.0/24, reachable only via bastion. nginx onapi-01proxies to Node on127.0.0.1:3000. - Data tier —
db-01on10.0.2.15, Postgres listens on private interface only. - Client config —
ProxyJumpentries forharbor-api,harbor-worker,harbor-db;LocalForward 5433 10.0.2.15:5432on the db host alias for DBA access. - Deploy — CI job rsyncs build artifacts to
harbor-apiandharbor-workerover SSH;systemctl restart harbor-apivia a forced command or short-lived deploy key with command restriction. - Verify —
ssh harbor-api 'curl -sf localhost:3000/healthz'; checkjournalctl -u harbor-api -n 20on 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
| Scenario | Recommended approach | Avoid |
|---|---|---|
| Human daily ops | Ed25519 key + passphrase + ssh-agent | Shared root password in a wiki |
| CI/CD deploy | Dedicated deploy key per repo/host, forced command or short TTL | Personal laptop key on the CI runner |
| First server bootstrap | Password or cloud console once, then keys + disable password | Leaving password auth on indefinitely |
| Private DB access | LocalForward through bastion | Public Postgres port 5432 open |
| Multi-hop internal hosts | ProxyJump in ~/.ssh/config | Agent forwarding to every hop |
| Large team | SSH certificates (Vault, step-ca) with short TTL | Manually copying 50 keys to authorized_keys |
| Legacy RSA-only systems | 4096-bit RSA temporarily, plan migration | 1024-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_configis broken. - Tunneling around security policy — local forwards can bypass corporate firewalls; know your org rules.
Practitioner checklist
- Ed25519 keys with passphrases;
IdentitiesOnly yesin config. - Password and root login disabled on internet-facing
sshd. - Firewall restricts SSH source IPs; fail2ban or equivalent enabled.
~/.sshpermissions 700; private keys 600;authorized_keys600.~/.ssh/configaliases 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.logor 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/configandProxyJumpturn 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
- Linux fundamentals explained — kernel, systemd, permissions, and VPS operations
- Bash shell scripting explained — deploy automation over SSH with rsync
- nginx fundamentals explained — reverse proxy and TLS in front of app servers
- CI/CD pipelines explained — SSH deploy keys in automated release flows