# SSH for developers

> SSH for developers: connect to VPS servers, create keys with ssh-keygen, use ~/.ssh/config aliases, and copy files with scp or rsync.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-09-02 | Topics: [CLI](https://flaviocopes.com/tags/cli/) | Canonical: https://flaviocopes.com/ssh-for-developers/

SSH lets you open an encrypted connection to another machine. We use it to manage servers, run remote commands, copy files, create tunnels, and authenticate Git operations.

The command looks simple:

```bash
ssh deploy@203.0.113.10
```

There are two separate trust questions behind it:

1. Is this the server I intended to reach?
2. Can I prove to that server who I am?

Host keys answer the first question. User keys or another authentication method answer the second.

## What happens during an SSH connection

SSH does more than encrypt a terminal session.

First, your client opens a TCP connection to the server. Both sides exchange protocol versions and choose the encryption, key exchange, host key, and integrity algorithms they support.

The key exchange creates temporary session keys. The server also signs part of that exchange with its host private key. Your client checks that signature against the host key stored in `known_hosts`.

Only then does user authentication begin.

With public key authentication, the server checks whether your public key is authorized. Your client signs data tied to the current connection with the private key. The server verifies the signature with the public key.

The private key never travels across the network.

After authentication, SSH opens one or more **channels** inside the encrypted connection. An interactive shell is one channel. A remote command, file transfer, or forwarded port can use another.

Here is the complete flow:

```mermaid
sequenceDiagram
  participant C as Your computer
  participant S as SSH server
  C->>S: Open a TCP connection
  C->>S: Agree on algorithms and session keys
  S-->>C: Prove the server identity
  C->>C: Check known_hosts
  C->>S: Prove the user identity
  S-->>C: Open encrypted channels
```

This model helps when debugging. A connection can reach the server but fail host verification. It can pass host verification but fail user authentication. Or it can authenticate correctly while a forwarded service remains unreachable.

## Connect to a server

The basic syntax is:

```bash
ssh user@host
```

Use `-p` when the server listens on another port:

```bash
ssh -p 2222 deploy@203.0.113.10
```

The IP address above belongs to the range reserved for documentation. Replace it with your server address.

If you are creating your first server, the free [Ubuntu VPS course](https://flaviocopes.com/courses/vps/) walks through provisioning, secure access, and deployment. The free [Networking Foundations course](https://flaviocopes.com/courses/networking-foundations/) explains addresses, ports, and routes.

## Verify the host key

The first connection shows a fingerprint similar to this:

```text
The authenticity of host '203.0.113.10' can't be established.
ED25519 key fingerprint is SHA256:...
Are you sure you want to continue connecting?
```

Do not type `yes` by reflex.

Compare the fingerprint with a value obtained through another trusted channel. A hosting provider may show it in the dashboard or console. A system administrator may give it to you directly.

After approval, SSH stores the host key in:

```text
~/.ssh/known_hosts
```

Future connections compare the server with that saved key.

If the key changes, SSH warns us. The server may have been rebuilt, but a changed key can also mean the address now reaches another machine or someone is intercepting the connection.

Investigate before removing the old entry. Once the change is confirmed, remove that host entry with:

```bash
ssh-keygen -R 203.0.113.10
```

Then connect again and verify the new fingerprint.

Never disable strict host checking to make the warning disappear. Encryption without server identity can protect a connection to the wrong server.

## Create an SSH key pair

Key-based authentication uses two related files:

- the **private key** stays on our machine
- the **public key** goes to the server

Create an Ed25519 key:

```bash
ssh-keygen -t ed25519 -C 'flavio@macbook'
```

SSH asks where to save it. The default files are:

```text
~/.ssh/id_ed25519
~/.ssh/id_ed25519.pub
```

The `.pub` file is public. We can place it on servers and Git hosting services.

The file without `.pub` is private. Never email it, paste it into a dashboard, or commit it.

Add a passphrase. It encrypts the private key on disk, so a stolen file is not immediately usable.

The passphrase does not get sent to the server. Your computer uses it locally to unlock the private key.

You can inspect the fingerprint of a public key:

```bash
ssh-keygen -lf ~/.ssh/id_ed25519.pub
```

This is useful when a server, Git provider, or teammate shows several keys. Compare fingerprints instead of guessing from filenames.

## Install the public key on the server

If the server accepts a password for initial setup, use:

```bash
ssh-copy-id deploy@203.0.113.10
```

This appends the public key to the remote user's `~/.ssh/authorized_keys` file and applies suitable permissions.

Some hosting providers install a public key when the server is created. In that case, no password login or `ssh-copy-id` step is needed.

If you install a key manually, copy the complete `.pub` line. Then run these commands on the server:

```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
```

The account must own both the directory and the file. OpenSSH commonly rejects `authorized_keys` when another user can modify it or its parent directory.

Each line can also restrict what a key may do. For example, an automated deployment key can run one command and nothing else:

```text
command="/usr/local/bin/deploy-notes",restrict ssh-ed25519 AAAAC3...
```

`restrict` disables features such as forwarding, agent forwarding, and pseudo-terminal allocation. The forced `command` ignores the command requested by the client.

Use restrictions for automation keys. Your normal interactive key needs a shell, so this exact entry would be too narrow for it.

Test the key in a new terminal:

```bash
ssh deploy@203.0.113.10
```

Keep the original session open while testing. Before disabling password login, prove that a second connection works. This simple habit prevents lockouts.

The server authorizes the public key for one user. A key installed for `deploy` does not automatically grant access as `root`.

## Protect local SSH files

SSH expects private configuration to have restrictive permissions:

```bash
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_ed25519
chmod 600 ~/.ssh/config
chmod 644 ~/.ssh/id_ed25519.pub
```

If a private key is readable by other users, SSH may refuse to use it.

Back up private keys carefully. A backup containing an unencrypted private key is another copy an attacker can steal.

## Use ~/.ssh/config

Typing a user, address, port, and key every time gets old. Put host-specific settings in `~/.ssh/config`:

```text
Host notes-server
  HostName 203.0.113.10
  User deploy
  Port 2222
  IdentityFile ~/.ssh/notes_server_ed25519
  IdentitiesOnly yes
```

Now connect with:

```bash
ssh notes-server
```

`Host` is the local alias. `HostName` is the real DNS name or IP address.

`IdentitiesOnly yes` tells SSH to use the configured identity instead of offering every key loaded in the agent. This avoids authentication failures when an agent contains many keys.

SSH reads command-line options first, then user configuration, then system configuration. For most settings, the first value found wins.

This makes file order matter. Put specific hosts before broad wildcard rules:

```text
Host production
  HostName 203.0.113.10
  User deploy

Host *.internal.example
  User flavio
```

Inspect the final configuration SSH will use:

```bash
ssh -G notes-server
```

This prints the resolved client configuration without connecting. It is very useful when aliases, wildcard blocks, or included files interact.

You can split a large configuration with `Include`:

```text
Include ~/.ssh/config.d/*
```

SSH processes included files at that position. Keep their permissions as restrictive as the main config.

## Manage several keys

I prefer a separate key when identities have different security boundaries, such as personal Git hosting and a production server.

Generate a named key:

```bash
ssh-keygen -t ed25519 -f ~/.ssh/notes_server_ed25519
```

Connect with it once from the command line:

```bash
ssh -i ~/.ssh/notes_server_ed25519 deploy@203.0.113.10
```

Then move that selection into `~/.ssh/config`.

Reusing one key across every server is easy, but revoking it affects everything. One key per server can become hard to manage. Group keys around real identities and risk boundaries instead of following an absolute rule.

This is the same key mechanism used for [Git over SSH](https://flaviocopes.com/how-to-setup-git-ssh-keys/).

## Use ssh-agent

A passphrase protects the key, but typing it for every connection is annoying. `ssh-agent` keeps unlocked keys in memory.

Start an agent in a shell and add the key:

```bash
eval "$(ssh-agent -s)"
ssh-add ~/.ssh/notes_server_ed25519
```

List loaded keys:

```bash
ssh-add -l
```

Remove one key:

```bash
ssh-add -d ~/.ssh/notes_server_ed25519
```

Desktop operating systems often start an agent and integrate it with their credential store. Check the platform's OpenSSH instructions before adding shell startup commands that create a new agent on every terminal.

## Avoid agent forwarding by default

Agent forwarding makes the local agent available through the remote host:

```bash
ssh -A notes-server
```

This does not copy the private key. However, a privileged attacker on the remote host may use the forwarded agent to authenticate elsewhere while the connection remains available.

Do not enable `ForwardAgent yes` globally. Use a jump host or a narrowly scoped deploy key instead when possible.

## Run one remote command

SSH does not require an interactive shell:

```bash
ssh notes-server 'systemctl --user status notes-api'
```

The remote command's output appears locally. Its exit status becomes the `ssh` command's exit status.

This makes SSH useful in scripts:

```bash
if ssh notes-server 'test -f /var/www/notes/current.txt'; then
  echo 'Release marker exists'
else
  echo 'Release marker is missing'
fi
```

Be careful with quoting. The local shell parses the command line first, then the remote shell parses the command string. For complicated logic, copy a tested script to the server and run it there.

Do not place secrets directly in the command. They may appear in shell history, process listings, or logs.

## Copy files with scp

Copy a local file to the server:

```bash
scp deploy.zip notes-server:/var/www/notes/
```

Copy a remote log to the current folder:

```bash
scp notes-server:/var/log/nginx/error.log ./error.log
```

Copy a directory recursively:

```bash
scp -r ./assets notes-server:/var/www/notes/
```

`scp` is good for a few files. See [download a file with scp](https://flaviocopes.com/download-file-scp/) for more examples.

## Synchronize folders with rsync

`rsync` compares source and destination, then sends the differences over SSH:

```bash
rsync -av ./dist/ notes-server:/var/www/notes/
```

The trailing slash on `./dist/` means "copy the contents of this folder." Without it, `rsync` creates a `dist` folder at the destination.

Preview a destructive synchronization with `--dry-run`:

```bash
rsync -av --delete --dry-run \
  ./dist/ notes-server:/var/www/notes/
```

`--delete` removes destination files absent from the source. Review the dry run, confirm both paths, then run the real command only when deletion is intended.

`rsync` is synchronization, not a backup. Mirroring a mistaken deletion can remove the other copy too.

## Forward a port to your computer

Local port forwarding exposes a remote-side service on our laptop.

Suppose Postgres listens only on the server's loopback interface at port 5432. Create a tunnel:

```bash
ssh -N -L 5433:127.0.0.1:5432 notes-server
```

Now a local client can connect to `127.0.0.1:5433`. SSH carries the traffic to `127.0.0.1:5432` as seen from `notes-server`.

`-N` means no remote command. The connection exists only for forwarding.

Add an option so SSH fails when it cannot create the tunnel:

```bash
ssh -N \
  -o ExitOnForwardFailure=yes \
  -L 5433:127.0.0.1:5432 \
  notes-server
```

By default, the local listener is intended for the local machine. Do not bind a database tunnel to every network interface unless other machines genuinely need access and the exposure is understood.

The easiest way to read `-L` is:

```text
-L local_port:destination_seen_from_server:destination_port
```

The destination is resolved from the SSH server, not from your laptop. That difference matters when the service uses a private hostname.

## Forward a local service to the server

Remote forwarding goes in the other direction.

Suppose an application runs on your laptop at port 3000. Make it available from the server at port 8080:

```bash
ssh -N -R 8080:127.0.0.1:3000 notes-server
```

Now a process on `notes-server` can connect to `127.0.0.1:8080`. SSH carries that traffic back to port 3000 on your laptop.

Remote forwarding is useful for short-lived previews and callback testing. Do not treat it as a permanent deployment system.

The SSH server controls whether remote forwarding is allowed. It also controls whether the listening port stays on loopback or can bind to other interfaces.

## Create a SOCKS proxy

Dynamic forwarding lets applications choose the destination:

```bash
ssh -N -D 1080 notes-server
```

This creates a SOCKS proxy at `127.0.0.1:1080`. Configure a browser or another SOCKS-aware client to use it.

Traffic from that application exits through `notes-server`. Other applications continue using their normal network path.

Be careful with DNS. Some clients resolve hostnames locally before using the proxy. Choose the client's remote-DNS option when DNS queries must travel through the tunnel too.

## Keep long connections healthy

Networks and firewalls sometimes drop idle connections. Add per-host keepalives:

```text
Host notes-server
  ServerAliveInterval 30
  ServerAliveCountMax 3
```

The client sends a message after 30 seconds of inactivity. It gives up after three unanswered messages.

This detects a dead connection. It does not keep a crashed remote process alive. Use `tmux`, `screen`, or a service manager for long-running server work.

## Leave a stuck session

SSH has escape commands for an interactive session. Press Enter, then type:

```text
~.
```

This disconnects the client when the remote shell or network is stuck. The escape character must appear at the start of a new line.

Use `~?` to see the available escape commands. These sequences are interpreted by your local SSH client, not by the remote shell.

## Debug a failed connection

Add verbose output:

```bash
ssh -v notes-server
```

Use `-vv` or `-vvv` for more detail.

The log shows configuration, address resolution, host-key negotiation, and authentication attempts. It often reveals the exact key SSH offered.

Common errors have different meanings:

- **Connection timed out**: routing, firewall, or wrong address
- **Connection refused**: the host answered but nothing accepts that port
- **Host key verification failed**: identity is unknown or changed
- **Permission denied (publickey)**: the server rejected every offered identity
- **Too many authentication failures**: the client offered too many keys before the right one

Do not solve every error by weakening security. Read the verbose output and fix the failing layer.

Test authentication without opening an interactive shell:

```bash
ssh -o BatchMode=yes notes-server true
```

`BatchMode=yes` disables password and passphrase prompts. This makes the command useful in automation because it fails instead of waiting for input.

If you administer the server, inspect its logs while making a test connection. On Ubuntu, a common command is:

```bash
sudo journalctl -u ssh --since '10 minutes ago'
```

Client logs explain what your computer attempted. Server logs explain why `sshd` accepted or rejected it. You often need both sides.

When you change server configuration, keep an existing session open. Validate the configuration before reloading:

```bash
sudo sshd -t
```

No output means the syntax check passed. This does not prove every access rule behaves as intended, so test a second login before closing the first session.

## Server-side security basics

For a VPS, I use these boundaries:

- a named administrative or deployment user instead of routine root login
- key authentication with a passphrase
- a firewall that exposes only required ports
- password login disabled only after key access is proven
- separate keys or users for automation where practical
- prompt operating system and OpenSSH updates

SSH protects transport and authentication. It does not make every remote command safe. The connected user can still delete files or expose secrets allowed by their permissions.

## How I use SSH

I put every regular server in `~/.ssh/config`. The alias records the user, address, port, and identity once. Commands, `scp`, `rsync`, and Git can all reuse it.

I verify host keys, use passphrases, and keep agent forwarding off. For deployments, I prefer a narrow user and one tested remote script over a large quoted command.

I would not expose a database publicly just to reach it from my laptop. A local SSH tunnel gives temporary access through the existing authenticated connection.

The free [SSH course](https://flaviocopes.com/courses/ssh/) goes deeper into keys, server hardening, automation, rotation, and recovery. The [Ubuntu VPS course](https://flaviocopes.com/courses/vps/) puts SSH into a complete server workflow.
