# SSH - From your first key to remote development A series of 9 article(s), in reading order. # GitHub - Connect your account using SSH and start to work with git@ protocol > Secure your GitHub connection! Follow this easy, step-by-step guide to set up SSH keys and use the git@ protocol for safer and quicker Git operations. --- ![GitHub - Connect your account using SSH and start to work with git@ protocol](/img/v2/github_tips.webp) **TL;DR** This article explains why connecting to GitHub over SSH is more secure than HTTPS, then walks through the setup: generating an ed25519 key pair with `ssh-keygen`, adding it to the SSH agent, and pasting the public key into GitHub's SSH key settings. It finishes with a quick `ssh -T git@github.com` command to verify the connection works, so you can start using the `git@` protocol with `git clone`. Using SSH instead of HTTPS to connect to GitHub is more secure. Indeed, SSH relies on public-key cryptography. This makes unauthorized access much harder compared to a password, which can be stolen through phishing or brute-force attacks. Also, HTTPS transmits your username and password (encrypted) over the network, which can be intercepted in a Man-in-the-Middle (MITM) attack. SSH doesn't transmit passwords after the initial setup. Let's see how to add an SSH key and, from now on, be able to work with GitHub using the `git@` protocol with `git clone`. Adding an SSH key to your computer and using it to connect to GitHub is quite easy. First, run the command below on your computer. Replace `your_email@example.com` with the email linked to your existing GitHub account. ```bash $ ssh-keygen -t ed25519 -C "your_email@example.com" ``` You'll be prompted to enter a *passphrase*, this is not required so just press `Enter`. You'll then see something like this on your console: ```bash Generating public/private ed25519 key pair. Enter file in which to save the key (~/.ssh/id_ed25519): Enter passphrase (empty for no passphrase): Enter same passphrase again: Your identification has been saved in ~/.ssh/id_ed25519 Your public key has been saved in ~/.ssh/id_ed25519.pub The key fingerprint is: SHA256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx your_email@example.com The key's randomart image is: +--[ED25519 256]--+ | ( ...... ) | | ( ...... ) | | ( ...... ) | | ( ...... ) | +----[SHA256]-----+ ``` Then you'll need to add the key to your SSH agent. Simply run: ```bash $ eval "$(ssh-agent -s)" $ ssh-add ~/.ssh/id_ed25519 ``` Finally, add the key to GitHub by surfing to . ![GitHub - Add SSH key](./images/ssh_add_key.webp) Give a clear title like f.i. `Home computer`. In the `Key` textarea, you'll need to paste there your **public** key. Take a look at what appeared on screen previously when running the `ssh-keygen` command. The public key path was mentioned; f.i., `Your public key has been saved in ~/.ssh/id_ed25519.pub`. So, just run `cat ~/.ssh/id_ed25519.pub` in your Linux console and you'll get the key value. Copy/paste that line into the GitHub settings page and, then, click on the `Add SSH key` button. Finally, if you want to test if the connection is successfully created, just run `ssh -T git@github.com`. You should get `Hi cavo789! You've successfully authenticated, but GitHub does not provide shell access.` (with your own pseudo of course). Two follow-ups: if you have repositories already cloned over HTTPS, [Git - Some tips for your .gitconfig file](/blog/git-config) shows a one-line setting to force SSH globally without re-cloning anything; and the same key mechanism works for your hosting server, see [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server). --- # Docker secrets - Using your SSH key during the build process > Securely access private Git repositories during your Docker build using SSH keys and Docker secrets. Includes complete Dockerfile and compose.yaml examples to prevent secrets from being stored in your final image. --- ![Docker secrets - Using your SSH key during the build process](/img/v2/docker_secrets.webp) **TL;DR** This article shows how to `git clone` a private repository during a Docker build without ever baking the SSH key into the final image, using Docker's `RUN --mount=type=secret` BuildKit feature declared in `compose.yaml` and referenced in the `Dockerfile`. It also covers the separate case of sharing your SSH key with the running *container* (not the image) via a bind-mounted volume, for ongoing `git pull`/`push` work inside it. There are plenty of articles on the Internet but I didn't find the one that allowed me, without an impressive amount of trial and error, to find the solution. So here's another article to add to the long list: how to access a private project stored on Github when creating a Docker image. In other words, the SSH key is not stored in the image. Docker will just use your key when executing the project recovery *layer* (the one containing the `git clone` instruction) and will not keep track of the key afterwards. My use case: I wish to build a Docker image and during the build phase, I need to grab a copy of a private repository I've put on github.com. *The CI equivalent of this problem — pulling a **private Docker image** rather than a private repository — is covered in [GitLab - Using Docker private images](/blog/gitlab-using-private-images).* When I access the container, the project will then be available but I don't have, anywhere in my image, a copy of my SSH key so I won't be able to run a `git pull` f.i. since no further authentication is possible. ## Preview ![You're authenticated](./images/authenticated.webp) That's the proof the technique works: the SSH key was used to authenticate against GitHub during the build — without ever being baked into the final image. ## Why it works * Docker's `RUN --mount=type=secret` mounts the secret only for the duration of that one build layer — it never gets written to a layer that ends up in the final image. * Copying your SSH key into the image instead (or hardcoding a token) is a serious design error: anyone reading the Dockerfile, or starting an interactive bash session and running `printenv` or browsing `.ssh`/`.git` folders, will find it. * Even a secret deleted in a later layer can survive: tools like [SecretScanner](https://github.com/deepfence/SecretScanner) do deep layer-by-layer scanning and will still find it if it was ever written to disk in an earlier layer. *The same reasoning applies to your git history: [Git - pre-commit-hooks](/blog/git-precommit) lists hooks that block a commit as soon as a credential is detected.* ## Which key to use > **Caution:** > > This part is one of the most important ones. First, of course, you should already have created an SSH key (see my [Github - Connect your account using SSH and start to work with git@ protocol](/blog/github-connect-using-ssh) if needed). > > Then you should know which protocol you've used and that's really important. Is your key stored in a file called `id_ed25519` or `id_rsa` or something else? Only you know. > > Just run `ls -alh ~/.ssh` to get the list of your keys: > > ![My own keys](./images/ssh_keys.webp) > > I'm using two different keys as you can see: `id_ed25519` or `id_rsa`. Files having the `.pub` extensions are the public keys; those without extensions are the private keys. It's important to know which key has been used to link your Github profile. You can just display the file to your console like f.i. `cat ~/.ssh/id_ed25519.pub` or `cat ~/.ssh/id_rsa.pub` (the public key file) and look at the end: is the mentioned email is the one you're using on Github.com? If yes, you've probably identified the key. In my case, the key I've used for github.com is `id_ed25519` so I'll use that one in the next chapter. ## Creating files We'll need two files: `compose.yaml` and `Dockerfile`. ### compose.yaml Here is my `compose.yaml` content: **compose.yaml** ```yaml services: app: build: context: . dockerfile: Dockerfile // highlight-next-line secrets: // highlight-next-line - my_ssh_key args: // highlight-next-line - KEY_NAME=id_ed25519 // highlight-next-line secrets: // highlight-next-line my_ssh_key: // highlight-next-line file: ${HOME}/.ssh/id_ed25519 ``` As you can see, we've five specific lines. In our `services -> app --> build` entry, we should add the `secrets` and the name of one (or more) secrets. I've also added an argument called `KEY_NAME` just because I need to inform Docker which key I'm using (is it `id_ed25519` or `id_rsa` or another one). Secrets have to be defined at the same indentation level of `services` and the notation is this one: **compose.yaml** ```yaml secrets: a_secret_name: file: existing_file ``` You can use what you want for `a_secret_name`; for instance, `my_ssh_key`. > **docker compose config:** > > You can, if you want, run `docker compose config` to check if your file is correct. You'll also see the full path for the used key. > > ![Docker compose config](./images/config.webp) ### Dockerfile Time to create our second file, `Dockerfile`: **Dockerfile** ```docker FROM alpine:3.19 ARG KEY_NAME="id_rsa" USER root RUN apk add --no-cache bash ca-certificates git openssh-client // highlight-next-line RUN mkdir -p -m 0700 /root/.ssh && ssh-keyscan "github.com" >> /root/.ssh/known_hosts // highlight-next-line RUN --mount=type=secret,id=my_ssh_key,dst=/root/.ssh/${KEY_NAME} \ // highlight-next-line mkdir -p /app && cd /app \ // highlight-next-line && ssh -T git@github.com || true \ // highlight-next-line && git clone git@github.com:cavo789/my_private_repo.git WORKDIR /app # Keep the container running ENTRYPOINT ["tail", "-f", "/dev/null"] ``` > **Remember to replace cavo789/my\_private\_repo.git with one of your own repositories:** As you can see, we are setting `KEY_NAME="id_rsa"` as the default value (but will be overwritten by our declaration in the yaml file) then later on, we need to create the `/root/.ssh` folder and we are adding `github.com` in the list of known hosts. The most important thing comes then. We should use the `RUN --mount=type=secret` syntax to inform Docker that this layer will use a Docker secret. We need to provide the name of our secret (which was set in our yaml file and it's `my_ssh_key` here) then we need to define where that secret has to be stored **during this layer**. Our variable `KEY_NAME` finds its use here: our SSH key was `id_ed25519` and this is the value of the `KEY_NAME` variable. So, in short `--mount=type=secret,id=my_ssh_key,dst=/root/.ssh/${KEY_NAME}` will be translated to `--mount=type=secret,id=my_ssh_key,dst=/root/.ssh/id_ed25519`. During this layer thus, our local SSH key will be saved to `/root/.ssh/id_ed25519`. The other lines in that `RUN` layer will first create a `/app` folder, jump in it, then run `ssh -T git@github.com` just for debugging (we expect to see on the screen `Hi your_name! You've successfully authenticated`) and finally we're git cloning our private repository using SSH. ## Create the image and jump in the container Ok, so you've the created the two files on your hard disk. Run `docker compose --progress plain build --no-cache` in your console to build the image and enable the verbose mode. As you can see, we can confirm that our SSH key was shared during the build process — that's the "You're authenticated" output shown at the top of this article. You'll now create the container; by running `docker compose up --detach`. ![The container is being created](./images/container_created.webp) Now, just to check, we can jump inside the container by running `docker compose exec app /bin/bash`. ![Starting an interactive console](./images/bash_session.webp) We can verify that our key wasn't stored in the image by running `ls -alh /root/.ssh/` ![No keys in the .ssh folder](./images/root_ssh_folder.webp) We can check too: jumping in our project folder and running `git pull` will fail with the error below: ```bash git@github.com: Permission denied (publickey). fatal: Could not read from remote repository. Please make sure you have the correct access rights and the repository exists. ``` And this is what we expect: our key isn't part of the image so we can't connect anymore to Github. ## Sharing your keys also with the container Ok, we've seen how to share the SSH key with Docker but only during the build phase. As we've seen above, jumping in the container and running `git pull` has failed; exactly what we wanted in the previous chapter. But now, if we need to share our key also with the container so we can work on the project, update it and push changes / pull modifications, how to do? Please edit the `compose.yaml` file and add these two lines: **compose.yaml** ```yaml services: app: build: context: . dockerfile: Dockerfile secrets: - my_ssh_key args: - KEY_NAME=id_ed25519 // highlight-next-line volumes: // highlight-next-line - ${HOME}/.ssh:/root/.ssh secrets: my_ssh_key: file: ${HOME}/.ssh/id_ed25519 ``` Then recreate the container by running `docker compose up --detach`. Jump in the container by running `docker compose exec app /bin/bash` and go in your project folder. Now, by running `git pull`, as you'll see, it'll work. Why? Because your SSH keys are now part of the container as we can see by running `ls -alh /root/.ssh`: ![Keys are now part of the container](./images/container_ssh_keys.webp) > **Keys are not stored in the image even when you've shared them with the container:** > > Just to be clear: the notion of volume we've just implemented concerns the container and not the image. In other words, SSH keys are not stored in the Docker image. You could give it to someone else (e.g. by saving the image on Docker Hub); your keys won't be there and will remain on your computer. Your image and your secrets are safe. > > Then, when the user run `docker compose up --detach` using the `compose.yaml` file where there are the two lines we've just added (the ones for adding the volume); these are his keys, on his computer, not yours. > **Please take note of the running user in your container:** > > In our example here, the default user is `root` as we can see by jumping in the container and running `whoami`. > > ![Whoami](./images/whoami.webp) > > So, when we've started `git pull`, it was under `root`. This is why we've mounted our volume like below: > > **compose.yaml** > > ```yaml > services: > app: > [...] > // highlight-next-line > volumes: > // highlight-next-line > - ${HOME}/.ssh:/root/.ssh > ``` > > Imagine the current user was `christophe`. In that case the mount should be done like this: > > **compose.yaml** > > ```yaml > services: > app: > [...] > // highlight-next-line > volumes: > // highlight-next-line > - ${HOME}/.ssh:/christophe/.ssh > ``` ## Conclusion Two different needs, two different mechanisms: a **build-time secret** (`--mount=type=secret`) that never touches the final image, for one-off operations like cloning a private repo during `docker build`; and a **bind-mounted volume**, for when the running container itself needs your key on an ongoing basis to `git pull`/`push`. Pick the first by default, and only add the second when you actually need to work from inside the container. --- # Using sftp on the command line, with or without a proxy > Connect to SFTP servers using the Linux command line. Get tips on automation with sshpass, setting custom ports, and configuring proxy server access. --- ![Using sftp on the command line, with or without a proxy](/img/v2/sftp.webp) **TL;DR** This article covers the `sftp` command line: connecting with `sftp user@host`, automating authentication with `sshpass`, routing the connection through an HTTP proxy via `-o ProxyCommand='/usr/bin/nc --proxy-type http --proxy ...'`, using a custom port with `-P`, and scripting remote commands (like downloading all `.zip` files) with a here-document passed to `sftp`. After my article [SSH - Launch a terminal on your session without having to authenticate yourself](/blog/linux-ssh-scp), it was obvious that, next to the `ssh` and `scp` commands, I forgot the `sftp` one. In this article, we'll explore how to start an SFTP connection to a remote server, from the command line. In the second section, we'll also learn how to configure the sftp connection to use a proxy server. *For the same operations from a graphical client on Windows, see [WinSCP - Synchronize host and remote](/blog/winscp-synchronize-both) and [WinSCP - Download files with specific extension recursively](/blog/winscp-download-recursively-files).* ## How to run an sftp connection The easiest way is `sftp @` so, if you need to connect to the server having the IP `1.2.3.4` with the user `christophe`, the command to start is simply `sftp christophe@1.2.3.4`. You'll be prompted to enter your password before being connected to the server. If you need some automation, you can install `sshpass` using `sudo apt-get update && sudo apt-get install sshpass`. > **Note:** > > The sshpass Linux utility is used by the script to allow an SFTP connection without having to use the password in plain text. [Get more info](https://www.redhat.com/sysadmin/ssh-automation-sshpass) Let's imagine your user is `christophe`, your password is `p@ssword` and the server IP is `1.2.3.4`, then you can connect like this: `SSHPASS="p@ssword" sshpass -e sftp christophe@1.2.3.4`. Here above, we're creating a temporary OS variable called `SSHPASS` with our password in plain text then run `sshpass -e` followed by our sftp command so `sftp @`. ## Using a proxy server Using a proxy is not so intuitive. The flag to use is `-o ProxyCommand=''` and a specific command. The command is `/usr/bin/nc --proxy-type http --proxy PROXY:PORT %h %p` where: * `proxy-type` should be initialized to `http` or `https` depending on your proxy. * `--proxy` should be set to the proxy domain name (f.i. `my.proxy.be`) and perhaps followed by `:8080` i.e. the port to use * then `%h %p` should be part of the command * `%h` is a placeholder that will be replaced by the hostname of the SFTP server you're trying to connect to. * `%p` is another placeholder that will be replaced by the port number of the SFTP server. The final command will look something like: ```bash $ sftp -o ProxyCommand='/usr/bin/nc --proxy-type http --proxy my.proxy.be:8080 %h %p' christophe@1.2.3.4 ``` And, if you want to use `sshpass`: ```bash $ `SSHPASS="p@ssword" sshpass -e sftp -o ProxyCommand='/usr/bin/nc --proxy-type http --proxy my.proxy.be:8080 %h %p' christophe@1.2.3.4` ``` ## Setting the port number to use for the SFTP server If your SFTP server is not running on port `22`, you'll need to specify the port number by using the `-P` flag: `sftp @ -P `. ## Running commands on the SFTP server then exit Imagine you wish to retrieve ZIP files from your SFTP server. As already seen, using `sshpass` we can bypass the authentication. We just need to make sure an `SSHPASS` variable exists just before calling the `sshpass` command. Then, to be able to automate some commands on the SFTP server, we can use a *here-document* syntax. The general look and feel will be this one: ```bash ( SSHPASS="" sshpass -e sftp @ << ! ! ) ``` So, if your user is `christophe`, your password is `p@ssword` and the server IP is `1.2.3.4`, we can connect to the server and, for instance, jump in a folder called `input_folder` then display the list of files / folders and, finally, download all zip files present in the folder: ```bash ( SSHPASS="p@ssword" sshpass -e sftp christophe@1.2.3.4 << ! cd input_folder ls -alh get *.zip ! ) ``` --- # SSH - Autosuggestions with ZSH > Get instant SSH autosuggestions in ZSH. Learn to install the zsh-ssh-config-suggestions plugin and display all your ~/.ssh/config aliases with a simple TAB press. --- ![SSH - Autosuggestions with ZSH](/img/v2/ssh.webp) **TL;DR** Stop memorizing server IPs and SSH commands. This article shows you how to streamline your SSH workflow with `zsh-ssh-config-suggestions`, a ZSH plugin that provides autosuggestions for your SSH connections. By leveraging your `~/.ssh/config` file, this plugin allows you to simply type `ssh ` and press Tab to see a list of all your configured server aliases. Learn how to install and configure this handy plugin to make connecting to your servers faster and more efficient. A few weeks ago, I've posted a [SSH - Launch a terminal on your session without having to authenticate yourself](/blog/linux-ssh-scp#using-the-config-file) article about the `ssh` command in Linux. I'm pretty sure, like me, you are tired of typing command lines like `ssh christophe@1.2.3.4` to start an SSH connection because ... you know, you don't need to connect on a server; no, you need to connect to the server where the application is running; you know the name of the *MyAmazingApp* application but certainly not the name of the server or its IP. That's the case for me anyway. So, I need to connect to my vault, in which I list all the information about applications, server names, credentials to be used, ... It would be pretty cool to run `ssh MyAmazingApp` no? *Two neighbours: [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server) to fill that `~/.ssh/config` in the first place, and [Master your ssh command and select the host from a list](/blog/ssh-with-fuzzy-finder) when autocompletion is no longer enough — around fifty hosts, a searchable list beats `TAB`.* Here's the result: type `ssh ` (with the trailing space) and press `TAB` to get every alias from your `~/.ssh/config` file, autosuggested. ![Using ssh-config-suggestions](./images/zsh-plugin-ssh-config-suggestions.gif) > **You should add a space character after `ssh`:** > > To make it working, please note: you should add a space character after having typed `ssh` and before pressing `tab`. ## Why It Works The plugin reads the `Host` aliases already declared in your `~/.ssh/config` file and wires them into ZSH's completion system — no separate list to maintain, no vault to open, just the config file you already have. It should be really nice to be able to run `ssh MyAmazingApp` and hop, I'm connected on the server. This is where the `~/.ssh/config` is so helpful (please refer to this [article](/blog/linux-ssh-scp#using-the-config-file)) but we can go one step further: it would be so great to type `ssh` and by some magic, Linux will show you the list of aliases defined in the `~/.ssh/config` file. Let's install it. ## Seeing It in Action with Docker No real server is ever contacted by this plugin — it only reads `Host` aliases from a config file — which makes it a perfectly safe one-command Docker test. > **Why Docker first?:** > > The Dockerfile below installs Oh-My-Zsh and the plugin, enables it in `~/.zshrc`, and writes the > exact `~/.ssh/config` shown further down. Nothing on your own machine changes, and nothing tries > to actually connect anywhere. **Dockerfile** ```docker FROM ubuntu:24.04 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ apt-get install -y --no-install-recommends \ zsh \ git \ curl \ ca-certificates && \ rm -rf /var/lib/apt/lists/* # Install Oh-My-Zsh unattended (host framework required by the plugin) RUN sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" \ "" --unattended --keep-zshrc # Install zsh-ssh-config-suggestions and enable it in the default plugins=() list RUN git clone --depth=1 https://github.com/yngc0der/zsh-ssh-config-suggestions.git \ /root/.oh-my-zsh/custom/plugins/zsh-ssh-config-suggestions && \ sed -i 's/^plugins=(git)/plugins=(git zsh-ssh-config-suggestions)/' /root/.zshrc # The exact ~/.ssh/config from this article — no real hosts are ever # contacted, only the Host aliases are read for completion RUN mkdir -p /root/.ssh && \ printf '%s\n' \ 'Host MyAmazingApp_PROD' \ ' Hostname 1.2.3.4' \ ' User usr_app_prod' \ '' \ 'Host MyAmazingApp_TEST' \ ' Hostname 6.7.8.9' \ ' User usr_app_test' \ '' \ 'Host YourAmazingApp' \ ' Hostname 1.2.3.4' \ ' User you' \ '' \ 'Host LegacyApp' \ ' Hostname 1.2.3.4' \ ' User legacy' \ > /root/.ssh/config && \ chmod 600 /root/.ssh/config # Signal clearly that this shell runs inside a container RUN echo "PROMPT='%B%F{yellow}🐳 %F{cyan}%n%f%b %~ %# '" >> /root/.zshrc CMD ["/bin/zsh", "-i", "-l"] ``` Build and run it: ```bash $ docker build -t ssh-suggest-demo . [+] Building 20.7s (8/8) FINISHED ✔ exporting to image $ docker run --rm -it ssh-suggest-demo 🐳 root ~ # ssh ``` Type `ssh ` (with the trailing space) and press `TAB` — the same four aliases from the "Use it" section below (`MyAmazingApp_PROD`, `MyAmazingApp_TEST`, `YourAmazingApp`, `LegacyApp`) show up immediately, read straight from the pre-baked `~/.ssh/config`. ## Installation of the zsh-ssh-config-suggestions plugin This assumes Oh-My-Zsh is already installed (see [How to install Oh-My-ZSH](/blog/zsh-install) if not). Just clone the official repository by running this command: `git clone https://github.com/yngc0der/zsh-ssh-config-suggestions.git ${ZSH_CUSTOM:-~/.oh-my-zsh/custom}/plugins/zsh-ssh-config-suggestions`. You'll get a local copy of the plugin in your `Oh-my-zsh` plugin folder. Then edit the `~/.zshrc` file, search for `plugins=(` and add `zsh-ssh-config-suggestions` to the list. You'll have f.i. something like: **\~/.zshrc** ```bash plugins=( git # ... your existing plugins zsh-ssh-config-suggestions ) ``` Close your console, open a new one (or run `source ~/.zshrc` to load your change) and it's done. ## Use it Imagine I've this content in my `~/.ssh/config`: **\~/.ssh/config** ``` Host MyAmazingApp_PROD Hostname 1.2.3.4 User usr_app_prod Host MyAmazingApp_TEST Hostname 6.7.8.9 User usr_app_test Host YourAmazingApp Hostname 1.2.3.4 User you Host LegacyApp Hostname 1.2.3.4 User legacy ``` Now, I really don't need to remember anything and I don't even need to know the names of the aliases any more! I just need to type (it's important): `ssh ` followed by `TAB` and I get the same suggestions already shown at the top of this article. As you can see, the system will display the list of hosts defined in my configuration file! I can then edit the file, add a new application and hop, next time, I'll get its name in the list. I don't need to connect to my vault anymore. Nice no? This is just one plugin among many; if your `~/.zshrc` is starting to grow with this kind of customization, have a look at [Beyond the Monolith - Organizing Your ZSH Workflow Like a Pro](/blog/modular-zsh-workflow) to keep it maintainable. --- # SSH - Launch a terminal on your session without having to authenticate yourself > Stop typing your SSH password! Learn how to use SSH keys and `ssh-copy-id` to launch a Linux terminal session instantly without having to authenticate yourself every time. --- ![SSH - Launch a terminal on your session without having to authenticate yourself](/img/v2/ssh.webp) **TL;DR** This guide explains how to set up passwordless SSH login to your Linux servers. Learn how to generate an SSH key pair, use `ssh-copy-id` to securely transfer your public key to the server, and connect without a password. The tutorial also covers using `scp` for secure file transfers and demonstrates how to create convenient shortcuts for your connections by using a `~/.ssh/config` file, allowing you to connect with a simple alias. Imagine that you regularly need to connect to your Linux server: you need to launch a tool such as Putty, you need to enter your login, password, etc. and carry out various operations before you can access the terminal. If your password isn't saved in Putty's configuration, you'll have to launch another tool like a password vault; in short, it's annoying. In this article, we'll look at how to authenticate once and for all on the server using an SSH key. *Two articles that build on this one: [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server), applied to a real hosting account, and [Master your ssh command and select the host from a list](/blog/ssh-with-fuzzy-finder) once your `~/.ssh/config` holds a dozen aliases.* > **Information:** > > To illustrate this article, let's assume `christophe` is the remote username (this is the username for the server, distinct from your local username) and `my_blog.be` is the remote server address. > > Remember to replace these two constants with yours ;-) ## TLDR Create an SSH key, copy it to the remote server and run an SSH connection. Steps 1 and 2 have to be done just once. **Steps to fire in your console:** 1. `ssh-keygen -t ed25519 -C "christophe@my_blog.be" -f ~/.ssh/id_ed25519_my_blog` 2. `ssh-copy-id -i ~/.ssh/id_ed25519_my_blog christophe@my_blog.be` 3. `ssh -i ~/.ssh/id_ed25519_my_blog christophe@my_blog.be` ## In-depth ### 1. Creating the SSH key The first thing to do, only once, is to generate an SSH key on your host for the remote server. By running `ssh-keygen -t ed25519 -C "christophe@my_blog.be" -f ~/.ssh/id_ed25519_my_blog` (or `ssh-keygen -t rsa -C "christophe@my_blog.be" -b 4096 -f ~/.ssh/id_rsa_my_blog` for the RSA algorithm): 1. You'll create an SSH key for the remote user `christophe` on server `my_blog.be`. You'll get a private key and a public one, 2. The key will be saved on your machine and the filename will be `~/.ssh/id_ed25519_my_blog` for the private key (and will have the `~/.ssh/id_ed25519_my_blog.pub` for the public one), 3. The key will be based on the ed25519 algorithm. ### 2. Copying the key When this is done, you've to copy the public SSH key to your remote server. To do this, please run `ssh-copy-id -i ~/.ssh/id_ed25519_my_blog christophe@my_blog.be`. This has to be done just once. 1. You'll copy your local SSH public key `id_ed25519_my_blog` on the `my_blog.be` server, 2. The key will be added for the remote user `christophe`, 3. You'll have to provide the password of that user when prompted. > **Information:** > > The `ssh-copy-id` command will append your public key to the `~/.ssh/authorized_keys` file on the server. ### 3. Connect to the server Now, everything is in place, you can run `ssh -i ~/.ssh/id_ed25519_my_blog christophe@my_blog.be` and it should work because the `id_ed25519_my_blog` public key was already copied on the server `my_blog.be` for the user `christophe`. > **Information:** > > You can display or edit the content of the `~/.ssh/authorized_keys` file by running `cat ~/.ssh/authorized_keys` (or `vi ~/.ssh/authorized_keys`). You can then remove old allowed keys f.i. ## Copying files from your host to the server Now that we can connect to the server so easily, we can f.i. copy files from our host like this: ```bash $ scp -i ~/.ssh/id_ed25519_my_blog local_file christophe@my_blog.be:/remote_file ``` First we need to specify our local file (or folder) then where to copy it. ## Copy files from the server to your host Now, in the other direction, from the server to your host: ```bash $ scp -i ~/.ssh/id_ed25519_my_blog christophe@my_blog.be:/remote_file local_file ``` ## Copying folders If you need to copy a folder, just add the recursive flag i.e. `-r`. For instance, `scp -r christophe@my_blog.be:~/backup/ .` will copy the entire `~/backup` folder from the server into my current, local, folder. Really handy. ## Tips ### Running ssh from Microsoft Terminal The SSH experience is much, much better using Microsoft Terminal than the old Putty application. With Microsoft Terminal, you can work in a full screen mode, you can have a nice background image, you can work with a much better font (the standard one with Putty is awful), use tabs, ... If you've already experienced Putty, you know what I mean. ### Using the config file As we have seen, when using the `ssh` or `scp` command, we need to specify the user name, the name or IP address of the server and the name of the file containing the private key. That's a lot of information to provide, isn't it? What's more, like me, I can't remember all this information. I just want to connect to my application server and that's it! It would be nice to be able to ssh my\_application and that's it. No? This is the purpose of the `~/.ssh/config` file! To connect on that server, my ssh command looks like this: `ssh christophe@my_blog.be -i ~/.ssh/id_ed25519_my_blog`. Not easy to remember! Let's create a `~/.ssh/config` file. If the file already exists, just edit it: `code ~/.ssh/config`. Add these lines in the file: **\~/.ssh/config** ``` Host my_app Hostname my_blog.be User christophe IdentityFile ~/.ssh/id_ed25519_my_blog ``` Save the file and go back to the console. Now, you can just run `ssh my_app` to make the connection. Pretty easy no? Want more info? Continue your reading ## Going further Once your `~/.ssh/config` file starts to grow with aliases, two companion articles are worth a look: [SSH - Autosuggestions with ZSH](/blog/zsh-plugin-ssh-config-suggestions), so you never have to remember an alias again, and [Using sftp on the command line](/blog/linux-sftp-cli) if you need the sftp counterpart of `scp`. --- # How to connect to your hosting server using SSH > Learn how to connect to your hosting server using SSH with key-based authentication and a simple alias for easy access. --- ![How to connect to your hosting server using SSH](/img/v2/planethoster-using-ssh.webp) **TL;DR** This guide explains how to configure SSH for passwordless, key-based authentication to your hosting server. It details gathering connection information, generating and copying an SSH public key, and setting up an alias in `~/.ssh/config`. This allows for convenient, one-command access like `ssh planethoster`. This guide explains how to configure SSH to connect to your hosting server using a short alias so you can SSH without entering a password each time you want to start the connection. You'll learn how to gather the required information, generate an SSH key pair, copy your public key to the server, and set up `~/.ssh/config` for a convenient shortcut. For this article, let's assume you have a PlanetHoster hosting account so, at the end of this guide, you'll be able to connect to your PlanetHoster server using a simple command like `ssh planethoster`. ## The Result Once the key and the `~/.ssh/config` alias are in place (covered below), starting a session is one command — immediate, no password prompt: ![Success](./images/success.webp) ## Why It Works * Your **public** key gets copied to the server's `~/.ssh/authorized_keys` — the server can verify your identity without ever seeing a password. * A short block in `~/.ssh/config` bundles the host, port, user and key into one alias — `ssh planethoster` instead of a four-flag command to remember. * Once the key is in place, there's nothing left to type or retype — the "password each time" annoyance this article opened with is gone for good. ## Installation **You'll need to obtain the following pieces of information:** 1. The username for the connection 2. The server name (or its IP address) 3. The port number to use 4. The password associated with the user account. For the first three items, log in to the [PlanetHoster dashboard](https://my.planethoster.com/v2/hosting-management/overview). You'll land on the dashboard page. In the left sidebar, navigate to **Web Hosting --> Account Management** and click the arrow to open the page: ![Opening your control panel](./images/web_hosting.webp) At the top of [the page](https://mg.n0c.com/), you'll find the three required pieces of information: * The *Current user*: the username to use for SSH. * Your server name (and its IP address). * The SSH port to use (likely `5022`). ![Your Planethoster Dashboard page](./images/planethoster_dashboard.webp) The fourth piece of information to gather is the password associated with your user account. *If you're using another hosting company, the steps to get this information will differ. Please refer to your hosting provider's documentation for details.* **For the rest of this article, let's assume these values:** 1. User: `john_doe` 2. Server name: `node30-eu.n0c.com` 3. Port number: `5022` 4. Password: `p@ssword` ### First, just try with a manual SSH connection In a Linux (or Windows Powershell) console, run the following command: ```bash $ ssh -p 5022 john_doe@node30-eu.n0c.com ``` You'll be prompted for a password; enter your account password. If everything is correctly configured, you will get a shell. You'll be located in your user's home directory (not the system root). Please type `ls -al` to list files and `pwd` or `whoami` to confirm you are in your home directory: ![Seeing the root directory using SSH](./images/ssh_logged_in.webp) > **What to do if you get a timeout:** > > If the `ssh` command takes a long time and then ends with a timeout, it likely means you are using incorrect information, such as the wrong username. Don't retry multiple times in a row, otherwise you'll block your IP address: your hosting company may detect multiple failed connection attempts, interpret them as a security threat, and block your IP address. If this happens, even with the correct command, you won't be able to connect due to the blacklisted IP. > > In this case, you'll have to wait a certain amount of time (depends on the hosting company). Best, if you're blocked, would be to create a support ticket and ask for your IP to be unblocked. ### Create an SSH Key to Connect to PlanetHoster In the previous step, we've confirmed that we can connect to the server with our credentials so let's improve the connection by setting up SSH key-based authentication. This will allow passwordless authentication for future connections. #### Create an SSH key On your local machine, run a command like `ssh-keygen -t ed25519 -C "john_doe" -f ~/.ssh/id_ed25519_hosting` in your terminal. *The very same key mechanism is used to talk to GitHub, as described in [GitHub - Connect your account using SSH and start to work with git@ protocol](/blog/github-connect-using-ssh).* This will create a private SSH key called `~/.ssh/id_ed25519_hosting` (and the associated public key `~/.ssh/id_ed25519_hosting.pub`). You'll be prompted to enter a passphrase. You can choose to set one for added security or leave it empty for convenience (in this article, I'm not using a passphrase). > **Information:** > > Are you curious? Run `cat ~/.ssh/id_ed25519_hosting.pub` to view the content of your public key. The key will look something like `ssh-ed25519 BASE64_STRING john_doe`. #### Copy your key to the server To be able to connect without a password, you need to copy your public key to the server. Please run `ssh-copy-id -i ~/.ssh/id_ed25519_hosting -p 5022 john_doe@node30-eu.n0c.com` to copy your public key to the hosting server. You'll be prompted to enter your password one last time. ```bash $ ssh-copy-id -i ~/.ssh/id_ed25519_hosting -p 5022 john_doe@node30-eu.n0c.com /usr/bin/ssh-copy-id: INFO: Source of key(s) to be installed: "/home/john_doe/.ssh/id_ed25519_hosting.pub" /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys john_doe@node30-eu.n0c.com's password: Number of key(s) added: 1 ``` Make sure you see a message indicating that the key(s) were added successfully. From now, you will be able to connect without providing a password. Run the command below as a test: ```bash $ ssh -i ~/.ssh/id_ed25519_hosting -p 5022 john_doe@node30-eu.n0c.com ``` As expected, you'll be immediately connected without a password prompt thanks to our SSH key. #### Simplify the Command with an SSH Config File The previous command is quite complex, isn't it? Are we able to simplify it? Yes! Let's simplify it so we can just use `ssh planethoster`. To do this, run `vi ~/.ssh/config` (or your preferred editor) to open your SSH configuration file. If the file doesn't exist yet, simply create it. Append the block below to the file (remember to update values according to your setup): **\~/.ssh/config** ``` Host planethoster HostName node30-eu.n0c.com User john_doe Port 5022 IdentityFile ~/.ssh/id_ed25519_hosting ``` Save and close the file (tip: it's `:w!` in vi). This done, make sure to set the correct permissions for your key by running `chmod 0600 ~/.ssh/id_ed25519_hosting*`. This makes the key files accessible only to you (private and public keys). > **WARNING: UNPROTECTED PRIVATE KEY FILE!:** > > If you don't set the correct permissions with `chmod`, you will get an error like the one below when you try to connect: > > ```text > @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ > @ WARNING: UNPROTECTED PRIVATE KEY FILE! @ > @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ > Permissions 0644 for '/home/john_doe/.ssh/id_ed25519_hosting' are too open. > ``` #### Establish an Easy SSH Connection From now on, to start an SSH connection to your host, open a terminal and run `ssh planethoster` (or the alias you defined). If everything is configured correctly, you will be connected **immediately** and **without a password prompt** — exactly the result shown at the top of this article. ## More Demos ### Troubleshooting & tips * If a connection fails, run the SSH client in verbose mode to see debug output: `ssh -vvv -p 5022 john_doe@node30-eu.n0c.com` (i.e. add `-vvv` as an extra CLI flag). * If you see a `WARNING: UNPROTECTED PRIVATE KEY FILE!` error, check local permissions on your private key and use `chmod 600` as shown earlier. * If you need to, connect to the server, verify that your key exists in `~/.ssh/authorized_keys` and that `~/.ssh` and `authorized_keys` have correct permissions (just run `vi ~/.ssh/authorized_keys` to open the file so you can edit it / remove unwanted keys). ## Conclusion This guide showed how to set up key-based SSH access to a hosting server and create a simple `ssh planethoster` shortcut. Once you've collected a dozen such aliases in `~/.ssh/config`, remembering them becomes the new problem; [Master your ssh command and select the host from a list](/blog/ssh-with-fuzzy-finder) turns them into an interactive picker. --- # SSH Remote development with VSCode > Use VS Code Remote - SSH: connect to production servers, and edit/execute remotely while avoiding common pitfalls. --- ![SSH Remote development with VSCode](/img/v2/vscode_ssh_dev.webp) **TL;DR** The article guides users on leveraging VS Code's Remote - SSH extension for direct development on remote servers. It details setting up connections, from a local Docker simulation to a production environment, to overcome network restrictions and avoid manual file transfers. This enables seamless editing and command execution directly on the remote host, integrating the full power of VS Code. In this article, we'll explore how to develop directly on a remote server using VS Code, editing files stored on the server without maintaining a local copy or performing manual uploads. My use case was simple: I needed to run a Python script on a Linux server that could access [an Oracle database](/blog/docker-oracle-database-server). However, my local development environment (my computer or a Windows VM) could not reach the database due to network restrictions. *Three related articles: [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server) to set up the key-based, passwordless connection this extension relies on; [One Docker Image for Production and Devcontainers](/blog/docker-prod-devcontainer) for the opposite approach — bringing the server's environment to your machine instead of the other way round; and [Open a DevContainer on a Remote Server with VSCode Remote SSH](/blog/vscode-remote-ssh-proxyjump-devcontainer) for the next step — opening a devcontainer on the remote host to reach services blocked from your local machine.* **Quick Jump:** [See it in Action](#the-result) | [Installation — Simulate a Server Locally with Docker](#installation--simulate-a-server-locally-with-docker) ## The Result Once the Remote - SSH extension is connected (covered below), VS Code edits and runs commands directly on the remote host — no upload, no local copy: ![Working on the production server](./images/vscode_ssh_planethoster.webp) ## Why It Works * No manual file transfer — the editor reads and writes directly on the remote filesystem. * The integrated terminal runs on the remote host itself, exactly as if you were logged in via SSH. * One tool for both editing and execution — no separate SSH session alongside a separate SFTP client. Let's dive into remote development with VS Code and SSH. ## Installation — Simulate a Server Locally with Docker First, we'll simulate a server locally with Docker so you can learn the workflow safely. Then, we'll repeat the same steps on a real production server. Create a Docker container to act as a Linux SSH server so you can practice connecting from VS Code. ### Create the Docker container that will act as our SSH server Create a `Dockerfile` with the content below. **Project: /tmp/remote-ssh** > **Note:** > > Now, please run 'docker build -t ssh-server .' to build the Docker image then 'docker run -d -p 2222:22 --name remote-dev ssh-server' to create the container. **Dockerfile** ```docker FROM ubuntu:latest # Install the SSH server so we can use the `ssh` command to connect to the container # Also create the required `/var/run/sshd` directory RUN apt-get update \ && apt-get install -y --no-install-recommends openssh-server \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ && mkdir -p /var/run/sshd # Create our test user (christophe) and assign him a password (p@ssword) RUN useradd -rm -d /home/christophe -s /bin/bash christophe \ && echo 'christophe:p@ssword' | chpasswd # Do some changes to the ssh configuration like disabling authentication with the `root` user RUN sed -i 's/^#PermitRootLogin.*/PermitRootLogin no/' /etc/ssh/sshd_config \ && sed -i 's/^#PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config \ && sed -i 's/^#ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config \ && sed -i 's/^UsePAM.*/UsePAM no/' /etc/ssh/sshd_config \ && sed -i 's/^#ListenAddress.*/ListenAddress 0.0.0.0/' /etc/ssh/sshd_config \ && sed -i 's/^#Port.*/Port 22/' /etc/ssh/sshd_config EXPOSE 22 CMD ["/usr/sbin/sshd", "-D"] ``` Next, we need to build our Docker image and create the container. To build it, run `docker build -t ssh-server .`. Then, create the container with `docker run -d -p 2222 :22 --name remote-dev ssh-server`. ```bash $ docker build -t ssh-server . $ docker run -d -p 2222:22 --name remote-dev ssh-server ``` The container acts as an SSH server and (for this demo) creates a user `christophe` with the password `p@ssword`. #### Test our container Test the container by accepting the host fingerprint and entering the password (`p@ssword`) when prompted. ```bash $ ssh christophe@localhost -p 2222 The authenticity of host '[localhost]:2222 ([127.0.0.1]:2222)' can't be established. ECDSA key fingerprint is SHA256:/XRLXCojjc9ykoYiuM0aEhDKu5MyZuUU793NokTqxlI. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added '[localhost]:2222' (ECDSA) to the list of known hosts. christophe@localhost's password: ``` Once connected, run commands like `ls -alh`, `hostname`, or `whoami`. ![Playing with the SSH terminal](./images/ssh_terminal.webp) > **Type `exit` to quit the terminal and return to your host.:** This quick test shows that the container is working and that we can connect to it using SSH. ## More Demos ### The Remote - SSH extension Our objective is to start VS Code and edit files directly on the server. To do this, we need to install the [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) extension from Microsoft. ![Install the Remote - SSH extension](./images/installing_remote_ssh_extension.webp) Once installed, you will see a new button on the left called **Remote Explorer**. Click it, and in the new pane, select `Remotes (Tunnels/SSH)` from the dropdown menu. (If you don't see it, confirm the extension is installed). ![Remotes (Tunnels/SSH)](./images/remote_tunnels.webp) Click the `+` button to create a new connection: ![Adding a new connection](./images/add_new_connection.webp) You will be prompted to enter the SSH connection string. In our example, it will be `ssh christophe@localhost -p 2222` because: * The user defined in our `Dockerfile` is `christophe`, * Our server is `localhost` (since it's a Docker container running on our machine), and * The port number to use is 2222 (the one we specified in the `docker run` command). VS Code will then ask which SSH configuration file you want to update. > **WSL2 & Windows users:** > > If you're using WSL2, note that VS Code running on Windows may not read your Linux `~/.ssh/config` file. Copy any needed entries into your Windows SSH config (typically `C:\Users\\.ssh\config`) and copy your SSH keys (public and private) too so VS Code can use them. > > Also, make sure the SSH key files are accessible to the client you are using. VS Code will create or update the chosen SSH config file and display something like this: ![The SSH config file](./images/ssh_config_file.webp) Now, if you click the **SSH** target in the left pane, you will see the remote-dev server with a connect icon next to it: ![The server is present under SSH](./images/ssh_unfolded.webp) Select the remote OS type (Linux), authenticate, and then open the user's home folder. You can create files (for example, `hello.sh`), make them executable, and run them from the integrated terminal. Running `hostname` shows the container's hostname (not your local machine's). > **The hostname is the Docker container ID:** For illustration, exit VS Code, go back to your terminal, and run `ssh christophe@localhost -p 2222`. We can see our `hello.sh` file: ![Checking the hello.sh Bash script](./images/ssh_check.webp) > **What we demonstrated:** > > We used a local Docker container as an SSH server to simulate remote development. The editor modifies files on the remote host, and the integrated terminal runs commands there—providing a smooth workflow for development and debugging. ### Repeat on a Real Production Server First, make sure your SSH configuration and keys are in place to connect to the production server. I explained how to create an SSH connection to a hosting server (PlanetHoster, in my case) in this post: [How to connect to your hosting server using SSH](/blog/connect-using-ssh-to-your-hosting-server). If you followed that guide, you should be able to connect to your production server using SSH keys and a configured `~/.ssh/config` alias (for example, `planethoster`). > **Because I'm working on WSL2:** > > However, something important in my situation is that I'm working on WSL2, so my Linux `~/.ssh/config` file **is not used** by VS Code (which is running on Windows). I had to copy the configuration to my Windows `C:\Users\Christophe\.ssh\config` file. In my case, I just opened Notepad and copied the lines I needed. > > I also had to copy my keys (public and private) from Linux to Windows. > > ![Windows SSH config file](./images/powershell.webp) > > Below is the content of my Windows SSH configuration: > > **C:\Users\Christophe\\.ssh\config** > > ```ssh\config > Host planethoster > HostName node30-eu.n0c.com > User john_doe > Port 5022 > IdentityFile ~/.ssh/id_ed25519_planethoster > ``` In VS Code, open the **Remote Explorer** pane and choose `Remotes (Tunnels/SSH)`. Select your configured host (for example, `planethoster`) and click the connect icon. Choose the OS (Linux) and the folder to open (usually the user's home directory). Once connected, edits and terminal commands affect the remote host directly—no uploads required. ![The Remote Explorer dropdown](./images/remote_explorer_dropdown.webp) ![Planethoster connection](./images/vscode_remote_planethoster.webp) ![Connecting to Planethoster](./images/vscode_connecting_planethoster.webp) You are now connected to your production server using VS Code and the Remote - SSH extension — the exact result shown at the top of this article. If you change any files, you are directly modifying those on the server. There is no longer a need to upload anything. > **Work carefully on production:** > > Editing files directly on a production server is risky—mistakes can cause downtime or data loss. Prefer SSH key authentication, test changes in a staging environment, keep backups, and perform risky operations during maintenance windows. ## Under the Hood (skip this if you just want to use it) **Bonus - A word about SSH key pair** Think of it like passwords. You could use one password for multiple services, but if that password is compromised, all your services are at risk. Everyone knows this, right? The same applies to SSH keys. If you use the same SSH key pair for multiple servers, and that key pair is compromised, all those servers are at risk. When you use an instruction like `ssh-keygen -t ed25519 -C "john_doe" -f ~/.ssh/id_ed25519`, `john_doe` is just a label to help you identify the key later. It doesn't add any security. It's not the username to connect to the server; it's just a comment. You can certainly use `ssh-keygen -t ed25519 -C "this is my super SSH key for all servers" -f ~/.ssh/id_ed25519` then use that same key for multiple servers. However, if that key is compromised, all of those servers are at risk. For this reason, I recommend creating a dedicated SSH key pair for each server you connect to. This way, if one key is compromised, only that specific server is at risk. ## Conclusion VS Code's [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) extension allows you to develop directly on remote hosts using the full power of your local editor and tools. Combined with a safe workflow (testing locally, using SSH keys, and following the security best practices mentioned above), it is a powerful option when network constraints prevent local development. Commands executed in VS Code's integrated terminal run on the remote host, just like being logged in via SSH. At the beginning of this article, I mentioned I could not connect to a database from my computer due to network restrictions. With the Remote - SSH extension, connecting to the server from the editor becomes possible and is relatively straightforward. --- # Open a DevContainer on a Remote Server with VSCode Remote SSH and ProxyJump > Chain VSCode Remote SSH, ProxyJump and Dev Containers to open a devcontainer that runs directly on a remote Linux server — so your scripts can reach services that are only accessible from that server's network. --- ![Open a DevContainer on a Remote Server with VSCode Remote SSH and ProxyJump](/img/v2/ssh-devcontainer.webp) **TL;DR** VSCode's Remote - SSH and Dev Containers extensions chain together: connect to a remote Linux server over SSH, then reopen the project inside a devcontainer running *on that server*. The container inherits the server's network privileges, so any service only reachable from that server — a private SFTP endpoint, a secured API — becomes reachable from your terminal. Two paths to get there: ProxyJump from your laptop (one click, transparent), or VSCode on an intermediate Windows VM (fallback if direct SSH isn't available). I needed to write a Bash script that fetches files from an SFTP server and sends POST requests to a secured API. Simple enough — except both sit on a private network segment only reachable from a specific Linux server. That Linux server itself is only reachable from a Windows VM at the office. My laptop, at the end of the chain, cannot see either service directly. Without the technique below, the complex workflow is: open a terminal on the VM, SSH to the Linux server, edit the script with `nano`, run it, copy-paste errors back to my editor. Not great. **Or I can skip the VM entirely and work straight from my laptop. This article shows both paths — and the direct-from-laptop route is simpler than it sounds.** With VSCode Remote SSH chained to Dev Containers, the workflow becomes: open VSCode, connect once, and work exactly as I would locally — full editor, full devcontainer, and the SFTP server and API are reachable from the integrated terminal. There are two ways to set this up, and the article covers both. *Prerequisite: you should already be comfortable with the basics of VSCode Remote SSH. If not, [SSH Remote development with VSCode](/blog/vscode-remote-ssh) covers the extension setup and the SSH config file from scratch.* **Quick Jump:** [Recommended Approach — ProxyJump from your laptop](#recommended-approach--proxyjump-from-your-laptop) | [Fallback Approach — VSCode on the Windows VM](#fallback-approach--vscode-on-the-windows-vm) | [Connect with VSCode](#connect-with-vscode) | [Open the DevContainer](#open-the-devcontainer) ## What You Get At the End The architecture, with the recommended approach, looks like this: ```mermaid graph TD A[💻 Your laptop] -->|"ProxyJump — transparent"| B[🖥️ Windows VM] B -->|SSH relay| C[🐧 Linux server] C -->|1 — fetch files| D[🗄️ SFTP server] D -->|files| C C -->|2 — POST requests| E[🔐 Secured API] style A fill:#4a90d9,color:#fff style B fill:#888,color:#fff style C fill:#27ae60,color:#fff style D fill:#8e44ad,color:#fff style E fill:#e67e22,color:#fff ``` VSCode runs on your laptop and connects to the Linux server in one click — the Windows VM is just a relay in the SSH config, invisible during normal use. The devcontainer runs on the Linux server, so it inherits the server's network: the SFTP server and the secured API are reachable from inside the container, even though they are invisible from your laptop. ## Which Approach Works for You? Two situations, two paths: ```mermaid flowchart LR L["💻 Your laptop"] V["🖥️ Windows VM"] S["🐧 Linux server"] L -->|"Recommended\nProxyJump\nVSCode on laptop"| S L -->|"Fallback\nManual session\nVSCode on VM"| V V --> S style L fill:#4a90d9,color:#fff style V fill:#888,color:#fff style S fill:#27ae60,color:#fff ``` > **SSH server on the Windows VM:** > > The recommended approach requires a SSH server on the Windows VM. If you're admin on the VM; it's just a command to run to install the SSH server. **To check if the recommended approach is available**, run this from your laptop terminal: ```bash ssh -J vm-user@windows-vm-ip dev-user@test.example.internal "whoami && hostname" ``` * It prints dev-user and the Linux server hostname → **use the recommended approach**. * It times out → your laptop cannot reach the VM at all → **use the fallback approach**. * `Connection refused` on port 22 → the VM is reachable but OpenSSH Server is not installed → **see the fix below**. *The second line is the output of `hostname` on the remote machine — it reflects the name set in `/etc/hostname`, which may differ from the address you used to connect. That is normal; what matters is that the command returned output rather than timing out or being refused.* **Fix: install OpenSSH Server on the Windows VM (requires admin rights)** `Connection refused` means the network path works — only the SSH service is missing. If you have admin rights on the VM, open a PowerShell **as administrator** and run: ```bash $ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0 $ Start-Service sshd $ Set-Service -Name sshd -StartupType Automatic ``` Once done, re-run the test from your laptop — it should now print dev-user and the server hostname. If this is the case, follow the **recommended approach**. **The key authorization step is optional.** The connection works with password authentication too — you will simply be prompted for your VM password each time VSCode connects. For occasional use that is fine; for daily use, setting up the key makes the connection fully silent. *Prefer a graphical install? Open **Settings → System → Optional features**, search for **OpenSSH Server**, and install it from there — then run the `Start-Service` and `Set-Service` commands above to start and enable it.* **Remark** If you've trouble when installing the OpenSSH Server (using the CLI or the GUI), as an alternative, you can download the `.msi` file by hand from . Once downloaded; double-click on the `.msi` file to start the installation. ## Why It Works * **Two extensions, one chain.** Remote - SSH opens a VS Code Server process on the Linux host. Dev Containers then starts a container on *that host* and connects VSCode to it — the container runs on the server's Docker engine, not your laptop's. * **Network privilege is inherited from the host.** The container's network stack lives on the Linux server, so every outbound connection goes out from the server's IP. Services that block your laptop's address let the server through. * **ProxyJump delegates the relay to SSH.** With `ProxyJump` in `~/.ssh/config`, SSH opens a connection to the VM, then tunnels a second SSH connection to the Linux server through it. VSCode sees only one host and one click. * **No file transfer.** Files live on the server. The editor reads and writes them directly — no `scp`, no rsync, no upload step. ## Configure SSH ### Recommended Approach — ProxyJump from Your Laptop #### Prerequisites Your SSH private key (`~/.ssh/`id\_ed25519) should be on your laptop — if you don't have one yet, [this article walks through generating an ed25519 key pair](/blog/github-connect-using-ssh). ProxyJump tunnels the authentication — your laptop's key authenticates to both hosts, so the key never needs to leave your laptop. Password authentication also works if key-based auth is not set up on the windows-vm-ip VM. #### Configure SSH on your laptop Create the `C:\Users\your_laptop_user\.ssh\config` (Windows; included WSL) or `~/.ssh/config` (if you're under Linux/macOS) file to your laptop's: ```txt Host linux-test Hostname test.example.internal User dev-user ProxyJump vm-user@windows-vm-ip IdentityFile ~/.ssh/id_ed25519 ``` The `ProxyJump` line tells SSH to reach linux-test by tunneling through the windows-vm-ip VM. From this point on, `ssh `linux-test from your laptop connects directly to the Linux server — no manual VM step. #### Test the full chain Run the command with the named alias now that the config is in place: ```bash $ ssh linux-test "whoami && hostname" ``` The first run will look like this: ```bash $ ssh -J vm-user@windows-vm-ip dev-user@test.example.internal "whoami && hostname" The authenticity of host 'windows-vm-ip (1.2.3.4)' can't be established. ED25519 key fingerprint is SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added 'windows-vm-ip' (ED25519) to the known hosts. vm-user@windows-vm-ip's password: The authenticity of host 'test.example.internal ()' can't be established. ED25519 key fingerprint is SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789BCDE. This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added 'test.example.internal' (ED25519) to the known hosts. dev-user@test.example.internal's password: dev-user test.example.internal ``` Two things happen here, both normal and both one-time only: * **Host-key prompts** (`The authenticity of host ... can't be established`): SSH has never seen these hosts before. Type `yes` for each — the fingerprint is stored in `~/.ssh/known_hosts` and the prompt never appears again. * **Password prompts**: you will see one for the VM and one for the Linux server — the connection works either way. To make it fully silent, authorize your public key on each host (see below). **Optional: eliminate password prompts with key-based auth** There are two hops, so two places to authorize your key. Do them in order. ##### Step 1 — authorize your key on the Windows VM For admin accounts on Windows, OpenSSH reads a system-wide file — **not** the per-user `~\.ssh\authorized_keys`. The directory `C:\ProgramData\ssh\` has restrictive ACLs: even a member of the Administrators group needs a **truly elevated** PowerShell session (right-click → *Run as Administrator*). On the VM: **Step 1a** — on your laptop, display your public key and copy the entire line: ```bash $ cat ~/.ssh/id_ed25519.pub ``` **Step 1b** — on the VM (PowerShell as Administrator), append the copied key: ```bash $ Add-Content C:\ProgramData\ssh\administrators_authorized_keys "ssh-ed25519 AAAA...your-key-here" ``` Re-run the test — the VM password prompt should be gone. The Linux server will still ask for a password; that is step 2. ##### Step 2 — authorize your key on the Linux server With ProxyJump already configured in `~/.ssh/config`, `ssh-copy-id` works transparently — it connects through the VM and copies your key to the Linux server in one command: ```bash $ ssh-copy-id linux-test /usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed /usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys dev-user@linux-test's password: Number of key(s) added: 1 Now try logging into the machine, with: "ssh 'linux-test'" and check to make sure that only the key(s) you wanted were added. ``` It asks for the Linux server password one last time, then adds your public key to `~/.ssh/authorized_keys` on the server. After that, the full chain is silent — no prompts at all. > **PowerShell: no ssh-copy-id available:** > > `ssh-copy-id` is not available in Windows PowerShell. Run this one-liner instead — with ProxyJump already configured, it routes through the VM automatically: > > ```bash > $ Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh linux-test "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" > ``` **Quick Jump:** [SSH configured — continue with VSCode](#connect-with-vscode) ### Fallback Approach — VSCode on the Windows VM Use this path if the recommended approach is not available (SSH server not installed on the windows-vm-ip VM, or the VM is not reachable from your laptop via SSH). #### Create the SSH config file on the VM Open a Powershell session on the windows-vm-ip VM. If the `$HOME\.ssh` subfolder does not exist yet, create it: `New-Item -ItemType Directory -Path "$HOME\.ssh"`. Open (or create) `C:\Users\`vm-user`\.ssh\config` in Notepad. The file must be called `config` with **no extension** — `config.txt` is silently ignored by every SSH client. ```txt Host linux-test Hostname test.example.internal User dev-user IdentityFile ~/.ssh/id_ed25519 ``` #### Copy your SSH private key to the VM The SSH client on the VM needs your private key. Because we can need your public key for later use, copy both manually via Explorer (navigate to your Linux home under `\\wsl$\` and copy `~/.ssh/`id\_ed25519 to `C:\Users\`vm-user`\.ssh\`), or run this one-liner in a Powershell session that has access to WSL: ```bash $ wsl sh -c 'cp "$HOME/.ssh/id_ed25519.pub" "/mnt/c/Users/$(cmd.exe /c echo %USERNAME% | tr -d "\r")/.ssh/id_ed25519.pub"' $ wsl sh -c 'cp "$HOME/.ssh/id_ed25519" "/mnt/c/Users/$(cmd.exe /c echo %USERNAME% | tr -d "\r")/.ssh/id_ed25519"' ``` #### Authorize your key on the Linux server With the public key now in `C:\Users\`vm-user`\.ssh\`, push it to the Linux server from PowerShell on the VM: ```bash $ Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh linux-test "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" ``` You will be prompted for your Linux server password once — that is the last time. From now on, `ssh `linux-test from PowerShell connects without a password prompt. *(WSL users: `ssh-copy-id `linux-test does the same.)* #### Test the connection from the VM From Powershell on the VM, confirm the alias resolves and authenticates: ```bash PS C:\Users\vm-user\.ssh> ssh linux-test No updates available to install Last update check: 2026-09-04 00:00:01 Last login: Thu Sep 3 17:41:10 2026 from 10.x.x.x [dev-user@linux-server-01 ~]$ whoami dev-user [dev-user@linux-server-01 ~]$ pwd /home/dev-user [dev-user@linux-server-01 ~]$ exit logout Connection to test.example.internal closed. PS C:\Users\vm-user\.ssh> ``` ## Connect with VSCode SSH is now configured and the test command returned your username — the connection works end to end. Whether you followed the recommended approach or the fallback, you are at the same point: ready to open VSCode and connect to the Linux server. > **WSL users — do not use `code .` for this step:** > > If you work exclusively in WSL, your reflex is `code .` from the terminal. **Do not use it here.** > > When VSCode opens via `code .` from WSL, it connects to WSL as a remote host. In that mode, the Remote Explorer only shows **WSL Targets** and **Dev Containers** — the **Remotes (Tunnels/SSH)** category never appears, no matter which extensions you install. > > Open VSCode the Windows way instead: from the **Start menu**, the **taskbar**, or a PowerShell terminal (just `code`, no arguments). That gives you a local window, and the full Remote Explorer dropdown — including **Remotes (Tunnels/SSH)** — will be there. So, **from your Windows-side; start a Powershell console** and install the **[Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh)** extension — it provides the Remote Explorer sidebar used to connect to the remote host: Prerequisite: Remote - SSH ```bash $ code --install-extension ms-vscode-remote.remote-ssh Installing extension 'ms-vscode-remote.remote-ssh'... Extension 'ms-vscode-remote.remote-ssh' v0.120.0 was successfully installed. ``` Verify: ```bash $ code --list-extensions | grep remote-ssh ms-vscode-remote.remote-ssh ``` > **On Powershell, use findstr instead of grep:** > > ```bash > $ code --list-extensions | grep remote-ssh > ``` Open VSCode — **on your laptop** if you followed the recommended approach, or **on the Windows VM** if you followed the fallback approach. In the **Remote Explorer** sidebar, select **Remotes (Tunnels/SSH)** from the dropdown. > WSL users: once again because it's important; start your Windows Start Menu and run VSCode from there. Locate linux-test in the list and click the arrow icon next to it to open it in a new window. VSCode deploys a small server binary on the Linux host the first time — watch the status bar at the bottom left for the *Opening Remote...* message. Once the host name appears there, you are connected. > **Host not visible in the list?:** > > Two things to check: > > 1. The config file extension. Open Explorer, navigate to `C:\Users\`vm-user`\.ssh\`, and confirm the file is called `config`, not `config.txt`. Windows Explorer hides known extensions by default — enable "File name extensions" in the View menu to be sure. > > 2. An outdated extension. If the host was visible before and has now disappeared, open the Extensions pane and update Remote - SSH. This has happened at least once after a VS Code update. > **You just opened a project on a server your laptop cannot directly reach:** > > VSCode is connected to the Linux server. You can already open files, run terminals, use Git — everything works from your laptop, on files that live on the server. The SFTP endpoint and the secured API are already reachable from the integrated terminal. Congratulations! > > **The next chapter is optional.** It adds a DevContainer on top of this connection — useful if your project requires a specific runtime or isolated environment. If you only need the remote editor, you are done. ### Verify the Connection Open a terminal in VSCode (**Terminal → New Terminal**) and run: ```bash $ hostname test.example.internal ``` The hostname matches the Linux server — the terminal runs on the remote machine, not your laptop. That network context is what makes the rest possible. Services invisible from your laptop but reachable from the server are now accessible from this terminal. If your project connects to an SFTP server, try it right now: ```bash $ sftp -i ~/.ssh/id_ed25519 -P 22 user@sftp.example.internal Connected to sftp.example.internal. sftp> ``` You are at an `sftp>` prompt from a terminal inside VSCode on your laptop — the Linux server's network does the routing, invisibly. ## Open the DevContainer Once connected to the remote host, install the **Dev Containers** extension to open the project inside a container running on that host: Prerequisite: Dev Containers ```bash $ code --install-extension ms-vscode-remote.remote-containers Installing extension 'ms-vscode-remote.remote-containers'... Extension 'ms-vscode-remote.remote-containers' v0.395.0 was successfully installed. ``` Verify: ```bash $ code --list-extensions | grep remote-containers ms-vscode-remote.remote-containers ``` Open the project folder on the Linux server, then press `F1` and run **Dev Containers: Rebuild and Reopen in Container** the first time (or **Reopen in Container** on subsequent connections). > **Docker must be installed on the Linux server:** > > Dev Containers requires Docker on the remote host, not on your laptop or the VM. If you see an error saying Docker is not found, your system administrator needs to install it, or follow the [official Docker installation guide](https://docs.docker.com/engine/install/) for your server's distribution. After the container starts, your terminal prompt changes to reflect the container environment. From here, the SFTP server and the secured API are reachable — both services that were invisible from your laptop are now on the local network of the container. ## Under the Hood (skip this if you just want to use it) **Remote - SSH** installs a `vscode-server` process on the Linux host the first time you connect, reused on subsequent connections. That server handles all editor communication — file reads, terminal I/O, language server protocol — over the SSH tunnel. **Dev Containers** then talks to the Docker socket on that *remote* host. It reads `devcontainer.json`, builds or pulls the image, and starts the container. VSCode reconnects its editor protocol to the container runtime, leaving the SSH layer underneath unchanged. The result is two nested connections: SSH tunnel → VS Code Server on host → container runtime. With the recommended approach, there is a third layer: the SSH tunnel itself goes through the VM via ProxyJump. The VS Code Server sees none of this — it only knows it is running on the Linux host. The ProxyJump is entirely at the SSH layer. The container's bind mounts reference paths *on the Linux server*, not paths on your laptop or the VM — which is exactly what you want. > **That test command is also a complete SSH session:** > > Remember the command from earlier, used to check whether the recommended approach was available? > > ```bash > ssh -J vm-user@windows-vm-ip dev-user@test.example.internal "whoami && hostname" > ``` > > Drop the quoted command at the end and it becomes a standalone one-liner that opens an interactive SSH session on the Linux server — straight from your laptop, with no SSH config file required: > > ```bash > ssh -J vm-user@windows-vm-ip dev-user@test.example.internal > ``` > > With the SSH config now in place, `ssh `linux-test does the same in one word. But the `-J` form works on any machine without touching `~/.ssh/config` first — handy whenever you need a quick session from an unfamiliar workstation. ## Bonus - ssh -J One-Liners All commands below work from any machine without touching `~/.ssh/config` — the full chain is encoded in the `-J` flag. **Quick check — runs a command and exits:** ```bash ssh -J vm-user@windows-vm-ip dev-user@test.example.internal "whoami && hostname" ``` **Interactive shell on the Linux server:** ```bash ssh -J vm-user@windows-vm-ip dev-user@test.example.internal ``` For interactive sessions that run a specific program, add `-t` to allocate a pseudo-terminal. Without it, the remote program starts but shows no prompt — the session looks frozen, but is actually waiting for input silently. **SFTP session on a server only reachable from the Linux host:** ```bash ssh -t -J vm-user@windows-vm-ip dev-user@test.example.internal sftp -i ~/.ssh/id_ed25519 user@sftp.example.internal ``` **PostgreSQL session on a database only reachable from the Linux host:** ```bash ssh -t -J vm-user@windows-vm-ip dev-user@test.example.internal psql -h db.example.internal -U postgres ``` **Port forwarding — use any GUI tool from your laptop:** For Windows GUI clients (HeidiSQL, DBeaver, TablePlus…) that can't run over SSH directly, port forwarding is the answer. It exposes the remote port as `localhost` on your machine — the GUI tool never knows it is crossing two SSH hops: ```bash ssh -fNL 5432:db.example.internal:5432 -J vm-user@windows-vm-ip dev-user@test.example.internal ``` Then connect HeidiSQL (or any client) to `Host: localhost`, `Port: 5432`. The tunnel runs silently in the background (`-f`) with no remote command (`-N`). Kill the `ssh` process when you are done. ## Conclusion The combination of VSCode Remote SSH and Dev Containers solves a problem that otherwise requires a clunky multi-step manual workflow: developing code that must run — or reach services that only exist — on a server your laptop cannot directly access. The recommended approach (ProxyJump) is the cleaner option: once the SSH config is in place, the connection is one click and the Windows VM becomes invisible infrastructure. The fallback approach is the reliable alternative when the VM's SSH server is not available. [SSH Remote development with VSCode](/blog/vscode-remote-ssh) is the companion article if you want to start from a simpler case — editing files on a remote host without a devcontainer, with a local Docker container as a safe practice target before touching a real server. --- # SSH Config Generator: Wildcard Stanzas, Aliases, and Automated Logins > Generate your SSH config in seconds — then understand how it works: one wildcard eliminates every repeated ProxyJump and IdentityFile, domain stanzas set usernames for FQDNs, short aliases need their own User line, and RemoteCommand lands you directly as the application user. --- ![SSH Config Generator: Wildcard Stanzas, Aliases, and Automated Logins](/img/v2/ssh.webp) **TL;DR** SSH reads every matching stanza top-to-bottom, first value wins per directive — that's what makes `Host * !bastion` powerful: one stanza covers every server at once, no more repeating `ProxyJump` and `IdentityFile`. Domain stanzas layer in the right `User` for FQDN connections; short aliases must repeat it explicitly, because SSH matches on what you type, not the resolved hostname. Three directives — `ServerAliveInterval`, `ControlMaster`, `ConnectTimeout` — harden the global defaults without changing their logic. For machines mixing corporate and personal SSH, `Include` splits the two cleanly. `RemoteCommand` turns a `sudo su` + `cd` sequence into a single alias. Every server in my environment is only reachable through my own Windows VM acting as a bastion. The [ProxyJump and DevContainers article](/blog/vscode-remote-ssh-proxyjump-devcontainer) shows a config with one alias. Add a second server and the config doubles. Add ten and it becomes a maintenance problem: change the bastion hostname once and you rewrite ten stanzas, each carrying the same `ProxyJump` and `IdentityFile` lines. The config below covers any number of servers with no repetition. ## The Config Both connection paths work out of the box — a short alias and a full FQDN, no flags needed: ```bash $ ssh project_prod "whoami && hostname" user-b server-prod $ ssh server-test.cloud.project-a.internal "whoami && hostname" user-a server-test ``` No `ProxyJump` flag, no explicit key — the config handles both transparently. Create `C:\Users\your_laptop_user\.ssh\config` (Windows; WSL included) or `~/.ssh/config` (Linux/macOS) with the following: ```txt # 1. Bastion — no ProxyJump, no IdentityFile Host windows-vm-ip User vm-user # 2. Domain defaults — in effect when connecting via full FQDN only Host *.cloud.project-a.internal User user-a Host *.cloud.corp.example User user-b Host *.office.corp.example User user-b # 3. Short aliases — HostName + User # (domain defaults above do not apply to aliases — see article) Host project_test HostName server-test.cloud.project-a.internal User user-a Host project_prod HostName server-prod.office.corp.example User user-b # 4. Explicit exceptions — bypass the bastion for specific hosts # Must come BEFORE "Host *" — SSH uses first-value-wins per directive Host github.com ProxyJump none # 5. Global defaults — fallback for every host except the bastion Host * !windows-vm-ip ProxyJump vm-user@windows-vm-ip IdentityFile ~/.ssh/id_ed25519 ``` ## How SSH Reads a Config File Three rules explain why the stanzas compose without conflict: * **Multiple stanzas can match.** Running `ssh project_prod` causes SSH to scan the file top-to-bottom and collect settings from *every* `Host` stanza that matches `project_prod`, not just the first one. * **First value wins per setting — the opposite of what most tools do.** In CSS, `.env` files, or most config systems, the last declaration overrides earlier ones. SSH does the reverse: the first value encountered is kept, and later stanzas can only fill in settings not yet defined. Put the most specific stanza first; the wildcard fallback last. * **Patterns match on the command-line argument, not on the resolved hostname.** `Host *.office.corp.example` matches `ssh server-prod.office.corp.example`. It does **not** match `ssh project_prod`, even though `project_prod`'s `HostName` resolves to `server-prod.office.corp.example`. ## The Five Sections The config snippet above is split into five numbered sections — here is what each one does and why it is placed in that order. **Section 1 — Bastion definition.** Declares the jump host with its username. No `ProxyJump` here (it would loop back to itself) and no `IdentityFile` (the VM uses whatever auth you have configured for it separately). **Section 2 — Domain defaults.** These match when you type a full FQDN like `ssh server-prod.office.corp.example`. SSH picks up the `User` from here, since no earlier stanza set it. Multiple domain stanzas with the same username are normal — one per domain suffix. These stanzas are also useful for scripts that construct hostnames programmatically or for `ssh-copy-id` calls. **Section 3 — Short aliases.** Each alias declares `HostName` (the real FQDN) and `User`. Adding a new server alias is exactly two lines. **Section 4 — Explicit exceptions.** Any host that must not go through the bastion — a Git hosting service, a CI runner, a SaaS API — gets `ProxyJump none` here. Position is load-bearing: this stanza must come *before* `Host *`. **Section 5 — Global defaults.** The `!` prefix in `Host * !`windows-vm-ip means "all hosts *except* the bastion". Placed last, this stanza provides `ProxyJump` and `IdentityFile` as fallbacks for every preceding stanza that did not set them — which is all of them. Any stanza in sections 2, 3, or 4 can override these defaults simply by declaring the directive first, without touching section 5. ## Hardening Section 5 (optional) Four directives extend the global defaults without changing their logic. Add whichever ones fit your environment — none are required for the config to work. ```txt # 4. Explicit exceptions — bypass the bastion for specific hosts # Must come BEFORE "Host *" — SSH uses first-value-wins per directive Host github.com ProxyJump none # 5. Global defaults — fallback for every host except the bastion Host * !windows-vm-ip ProxyJump vm-user@windows-vm-ip IdentityFile ~/.ssh/id_ed25519 # Optional — prevent silently-dropped idle connections ServerAliveInterval 30 ServerAliveCountMax 3 # Optional — fail fast instead of waiting the default 2 minutes ConnectTimeout 5 ``` * **`ServerAliveInterval 30` / `ServerAliveCountMax 3`** — SSH sends a keepalive packet every 30 seconds. After 3 unanswered packets (90 seconds of silence), the connection is dropped cleanly rather than hanging with a frozen prompt. Without these, an idle terminal on a corporate server often locks up with no error message. * **`ConnectTimeout 5`** — fails after 5 seconds if the server is unreachable. On a corporate network with a reliable bastion, the default 2-minute timeout is pure noise. > **ControlMaster does not work on Windows OpenSSH:** > > `ControlMaster auto` / `ControlPath` / `ControlPersist` enable SSH multiplexing: the first connection opens a Unix domain socket in `~/.ssh/`; subsequent connections reuse the tunnel instead of negotiating a new one through the bastion. On Linux and macOS this works well. > > On **Windows OpenSSH with ProxyJump**, these three directives silently break all connections — `ssh` hangs or exits with `Read from remote host: Unknown error` — because Windows's Unix socket implementation does not interoperate with ProxyJump's pipe-based stdio forwarding. The comment `# Optional` is not enough protection: the lines are copied and break things. > > Leave them out on Windows. If you connect from Linux or macOS, add them: > > ```text > ControlMaster auto > ControlPath ~/.ssh/cm_%C > ControlPersist 10m > ``` > > `%C` is a hash of the connection parameters — no colons, no special characters. Never use `%r@%h:%p`: the `:` is illegal in Windows filenames and silently corrupts the socket path even when ControlMaster itself is otherwise functional. ## Why Each Alias Needs Its Own `User` Line (skip if the config just works for you) The domain stanzas in section 2 set `User` only when SSH matches them — and as noted above, SSH matches patterns on the command-line argument, not on the resolved hostname. Running `ssh project_prod`: 1. Does **not** match `Host *.office.corp.example` (section 2) — `project_prod` is not a FQDN. 2. Matches `Host project_prod` (section 3) → applies `HostName server-prod.office.corp.example` and `User `user-b. 3. Does **not** match `Host github.com` (section 4) — `project_prod` is not in that list. 4. Matches `Host * !`windows-vm-ip (section 5) → applies `ProxyJump` and `IdentityFile`. Without the `User` line in the alias stanza, SSH falls back to your local Windows username for that connection — which is almost certainly wrong. Running `ssh server-prod.office.corp.example` directly: 1. Matches `Host *.office.corp.example` (section 2) → `User `user-b. 2. Does **not** match `Host github.com` (section 4) — not in that list. 3. Matches `Host * !`windows-vm-ip (section 5) → `ProxyJump`, `IdentityFile`. Both paths land on the right user. They just need different stanzas to get there. > **ZSH users: autocomplete your aliases:** > > The [zsh-ssh-config-suggestions plugin](/blog/zsh-plugin-ssh-config-suggestions) reads `~/.ssh/config` and offers completions on `ssh `. Every alias in section 3 becomes a suggestion — the longer your alias list, the more useful it is. ## One Caveat: The Wildcard Is Truly Universal (skip if this machine is corporate-only) `Host * !`windows-vm-ip applies to **every** SSH connection from this machine — including `ssh github.com` or `ssh localhost`. Connections to external hosts will attempt a `ProxyJump` through the VM and fail if the VM is not reachable. > **Devcontainers: declare exceptions before Host \*:** > > When the SSH config is bind-mounted into a devcontainer, the bastion hostname may not resolve inside the container network. Any SSH connection from the container — `git push`, `scp`, `rsync` — hits `Host *`, attempts a `ProxyJump` through the bastion, fails to resolve it, and exits with: > > ```text > ssh: Could not resolve hostname your-bastion: Name or service not known > ``` > > Hosts that must bypass the bastion need an explicit `ProxyJump none` stanza **before** `Host *`. SSH applies first-value-wins per directive: a `ProxyJump none` placed after `Host *` is silently ignored. Section 4 in the config above exists precisely for this — add any host that connects directly (Git hosting, CI runners, third-party APIs) to that stanza. Three options if that matters: * **Replace `Host *` with an explicit domain list:** `Host *.project-a.internal *.cloud.corp.example *.office.corp.example`. Connections to anything outside that list skip the bastion entirely. * **Keep `Host *`** if this machine uses SSH exclusively for servers inside your corporate network. In a dedicated work machine, the wildcard is fine. * **Override specific hosts above section 4.** Because the global defaults are last, any stanza placed before them wins. Add an exemption anywhere above section 4 — position within sections 1–3 does not matter: ```text # Anywhere above section 4 — fits naturally in section 3 alongside the other aliases Host github.com gitlab.com ProxyJump none IdentityFile ~/.ssh/id_ed25519_personal # Section 4 — global defaults (last, unchanged) Host * !windows-vm-ip ProxyJump ... ``` * **Split the config with `Include`** — the cleanest solution for a machine used for both corporate and personal SSH: ```text # ~/.ssh/config Include ~/.ssh/config.d/work Include ~/.ssh/config.d/personal ``` `~/.ssh/config.d/work` holds the full corporate config (bastion, domain stanzas, aliases, global defaults last). `~/.ssh/config.d/personal` holds a clean config with no bastion routing. The `Include` directives are processed top-to-bottom before any `Host` block in the main file — OpenSSH 7.3+ required. ## Bonus: Log In Directly as Another User Some servers require you to connect with your own account and then switch to an application user — `sudo su app_user` followed by `cd /opt/project`. The `RemoteCommand` directive automates the entire sequence from the alias. The generator below produces **two aliases per server**: a plain alias for VSCode Remote Explorer (no `RemoteCommand`) and a `_app` variant for terminal sessions that lands you directly as the application user. ## Generate Your Complete Config Fill in your values once and copy the result directly into `~/.ssh/config`. For a second project, copy-paste only the Section 3 aliases and change the project name — the bastion and global defaults are already in place. > **Test your bastion connection first:** > > Before testing a full alias, verify the bastion itself is reachable and accepts your key: > > ```bash > $ ssh vm-user@windows-vm-ip "echo bastion-ok" > bastion-ok > ``` > > `echo bastion-ok` is a non-interactive command — it exits immediately after printing, leaving no open session. If the output appears, section 1 and section 4 are wired correctly. If it hangs or fails, the problem is between your machine and the bastion, independently of any server alias. > **Eliminate password prompts — push your public key to a server:** > > Once the config is in place, authorize your key on any server with this PowerShell one-liner. Replace `project_test` with the alias or hostname you want to reach: > > ```bash > $ Get-Content $env:USERPROFILE\.ssh\id_ed25519.pub | ssh project_test "mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" > ``` > > You will be prompted for the server password once. After that, `ssh project_test` connects silently. On Linux and macOS, `ssh-copy-id project_test` does the same thing. > **VSCode Remote Explorer does not work with RemoteCommand:** > > VSCode Remote SSH injects its own server process during login. `RemoteCommand` replaces the remote shell before that injection can happen — the connection closes before VSCode gets a foothold. Use the plain `project_test` / `project_prod` aliases for Remote Explorer; navigate to the application directory from within VSCode once connected. The `_app` variant's `RemoteCommand` chains two things: `cd` to the application directory and `exec bash -l` to start a login shell as the application user. `RequestTTY yes` allocates a pseudo-terminal — without it, the session closes immediately after the command instead of giving you an interactive shell. ```bash $ ssh project_test_app mass_upload@server-test:/opt/folder_devcontainer$ ``` ```bash $ ssh project_prod_app app-user@server-prod:/opt/project$ ``` All four aliases get the global `ProxyJump` from section 4 automatically. > **sudo must be passwordless — or use su instead:** > > `sudo -u `app-user in a `RemoteCommand` works silently only when the sudoers file grants your account `NOPASSWD` for that specific user. If it does not, the connection will block waiting for a password that SSH cannot deliver before the TTY is fully established. > > If passwordless sudo is not configured, use `sudo su - `app-user instead — with `RequestTTY yes` the TTY is allocated before the command runs, so the password prompt appears normally: > > ```text > RemoteCommand sudo su - app-user -c "cd /opt/project && exec bash -l" > ``` > > You will see the sudo password prompt once, then land in the shell. ## Production Environment Warning A `RemoteCommand` banner is visible only to whoever has that SSH alias set up. The right place for a production warning is the **server itself** — so it reaches every user, whatever client they use. Drop a script in `/etc/profile.d/` on the server. Every interactive shell sources that directory on login: ```bash title="/etc/profile.d/env-banner.sh (on the server)" #!/usr/bin/env bash case "$(hostname -s)" in *prod*|*prd*) printf '\033[38;5;88;48;5;224m ⚠ Production server — be careful! \033[0m\n\n' export PS1='\[\033[38;5;88;48;5;224m\] ⚠ PROD \[\033[0m\] \u@\h:\w\$ ' ;; esac ``` The color pair `38;5;88` (dark maroon) on `48;5;224` (pale rose) is clearly visible without being aggressive — a reminder, not a warning sign. ```bash $ ssh project_prod_app ⚠ Production server — be careful! app-user@server-prod:/opt/project$ ``` Because `exec bash -l` in the `RemoteCommand` starts a login shell, the profile scripts run and the banner appears on every `_app` connection as well. No change to the SSH config needed. > **hostname-based detection vs. an explicit file:** > > `hostname -s` matching `*prod*` is convenient but fragile — a host named `deployments-node` would match nothing. An explicit marker file is more reliable: > > ```bash > if [[ -f /etc/env-type ]] && grep -qx "production" /etc/env-type; then > ``` > > Create `/etc/env-type` with the content `production` when provisioning a server. The check is unambiguous regardless of the hostname. ## Conclusion One wildcard stanza eliminates the per-alias repetition of `ProxyJump` and `IdentityFile`. Domain stanzas layer in usernames for direct FQDN access. Short aliases repeat `User` explicitly — because SSH matches on what you type, not on what the alias resolves to. Adding a new server means two lines: `HostName` and `User`. The routing is already handled. When a server requires a specific user and directory, `RemoteCommand` turns that sequence into a single alias. And when a production server needs a clear visual warning for everyone, `/etc/profile.d/` is the right place — not the SSH config. If you need to [generate or manage your SSH keys](/blog/github-connect-using-ssh), that article covers `ed25519` key generation and GitHub authorization from scratch — the same key used in the `IdentityFile` line above. ---