Using zed with remote dev containers through an SSH wrapper

Zed can’t connect to dev containers on a remote host yet, see issue #59500.

I’ve set up a workaround for this using a “normal” Zed Remote Development using SSH. The idea is to connect to the remote host’s SSH server, which routes the commands Zed sends over SSH into a dev container using docker exec.

The approach works as follows:

  1. On the client: create a new, dedicated SSH key pair for dev container development. Then create a new SSH host alias which forces the use of this key.
  2. On the remote host: install a wrapper script that rewrites incoming SSH commands and routes them into the docker container. Force the usage of this wrapper script (only) for connections identified by the key from step 1.
  3. In the dev container: install an SFTP server if you want to use Zed extensions. This is required because Zed needs to be able to upload extensions from the client to the host using SFTP. This works through a docker exec pipe and does not need sshd in the container.
  4. Back on the client, using Zed: attach to the dev container by connecting to your SSH host alias.

One important limitation to be aware of, is that this does not directly let Zed create new dev containers or run dev container hooks like postCreateCommand. It only lets Zed attach to already running dev containers on the host. For me that did not matter much, since I was anyway using the reference devcontainers/cli to manage dev containers on the host.

The rest of this note explains these steps in more detail. I set up this approach with the assistance of Opus 4.8 and a few rounds of iteration, letting Opus look at Zed’s client-side logs and the Zed server state1. A less hacky alternative to this approach might be to install an SSH server inside the dev container directly and publish the SSH port on the host, but I preferred to keep sshd out of my dev containers.

I’ve been using this approach myself successfully for some weeks now.

For now, the below only explains connecting to a single dev container. The approach should be scalable to multiple projects/dev containers simultaneously on the same host with some modifications2.

1. On the client

Create a new, dedicated key

ssh-keygen -t ed25519 -f ~/.ssh/zed_dc -C zed-devcontainer -N ""
ssh-copy-id -i ~/.ssh/zed_dc.pub youruser@yourhost   # or append the .pub line manually

Install it in ./ssh/config

Host devc
    HostName YOUR_REMOTE_HOST
    User YOUR_REMOTE_USER
    IdentityFile ~/.ssh/zed_dc
    IdentitiesOnly yes

2. On the host

Install the wrapper script

Don’t forget to make it executable by your user. Note, the following script is almost wholly LLM-output.

#!/usr/bin/env bash

set -euo pipefail

# Container name or ID
CONTAINER="YOUR_CONTAINER_NAME"
# User inside the container
CUSER="YOUR_USER" # e.g. "node"
# Working dir inside the container
CWD="YOUR_CONTAINER_WD"

cmd="${SSH_ORIGINAL_COMMAND:-}"

# ---------------------------------------------------------------------------
# Binary transfer subsystems FIRST. Zed uploads its extensions (Astro, Biome,
# …) over SFTP/SCP. These are strict binary streams: no tty, no login shell,
# no env munging — a single stray byte on stdout corrupts the transfer.
# ---------------------------------------------------------------------------
case "$cmd" in
  *sftp-server*|sftp)
    # The host sshd hands us ITS sftp-server path (e.g. /usr/libexec/openssh/
    # sftp-server), which doesn't exist in the container. Locate and exec the
    # container's own sftp-server instead. `exec` inside makes the binary own
    # the pipe directly, so nothing else can touch the stream.
    exec docker exec -i -u "$CUSER" "$CONTAINER" sh -c '
      for p in /usr/lib/openssh/sftp-server \
               /usr/libexec/openssh/sftp-server \
               /usr/lib/ssh/sftp-server \
               /usr/lib/sftp-server; do
        [ -x "$p" ] && exec "$p"
      done
      echo "zed-into-container: no sftp-server in container" >&2
      exit 127' ;;
  scp\ *)
    # Legacy scp fallback (rcp sink/source mode). Non-login bash keeps stdout clean.
    exec docker exec -i -u "$CUSER" "$CONTAINER" bash -c "$cmd" ;;
esac

# ---------------------------------------------------------------------------
# Interactive terminals and remote commands below.
# ---------------------------------------------------------------------------

# The container user's real login shell (zsh here), read from /etc/passwd once.
USER_SHELL="$(docker exec -u "$CUSER" "$CONTAINER" getent passwd "$CUSER" 2>/dev/null | cut -d: -f7)"
USER_SHELL="${USER_SHELL:-/bin/bash}"

# Exporting SHELL fixes Zed's shell detection (`sh -c 'echo $SHELL'`); without it
# docker exec leaves SHELL unset and Zed falls back to spawning dash (no readline).
flags=(-i -e "SHELL=$USER_SHELL")

# Mirror our stdin's TTY-ness into docker exec. sshd gives us a pty only when the
# client asked (`ssh -t`); Zed's terminal does, its probes don't. Without -t the
# container gets a pipe and the shell has no line editing (no Tab, no Ctrl+L).
if [ -t 0 ]; then
  flags+=(-t -e "TERM=${TERM:-xterm-256color}" -e "COLORTERM=${COLORTERM:-truecolor}")
  # Zed's interactive terminal command ends in a hardcoded `sh -l`; upgrade that
  # trailing token to the user's login shell (zsh). Probes use `sh -c '...'` and
  # don't end in ` sh -l`, so they're untouched.
  case "$cmd" in
    *' sh -l') cmd="${cmd% sh -l} $USER_SHELL -l" ;;
    *' sh')    cmd="${cmd% sh} $USER_SHELL -l" ;;
  esac
fi

case "$cmd" in
  "")
    # bare login (plain `ssh <host>`, no command): drop into the login shell
    exec docker exec "${flags[@]}" -u "$CUSER" -w "$CWD" "$CONTAINER" "$USER_SHELL" -l ;;
  *)
    exec docker exec "${flags[@]}" -u "$CUSER" -w "$CWD" "$CONTAINER" bash -lc "$cmd" ;;
esac

Force usage of the wrapper script

Modify the line of your just-added public key with the following options:

command="/usr/local/bin/zed-into-container",no-agent-forwarding,no-port-forwarding,no-X11-forwarding,no-user-rc ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI... zed-devcontainer

3. Install an SFTP server in the dev container

On my Debian-based dev container, that meant adding the package openssh-sftp-server to the Dockerfile.

This is optional, and not required if you don’t want to use any extensions or LSP providers. It will lead to (silent) errors though on the connection.

4. Connect in Zed using SSH

Via the Projects -> Open Remote -> Connect SSH server, then pick your configured host alias.

Footnotes

  1. I think Opus got inspiration for the approach from another issue, where the OP tried something similar, but didn’t get it to work, see https://github.com/zed-industries/zed/issues/30877#issue-3070834491

  2. A naïve way to scale this is to duplicate the entire approach: a new key set, a new host alias, an new wrapper script for each new project. I’ve done this duplication now, once, successfully.

    According to Claude, a cleaner approach is possible by specifying the properties that vary by project (container name, cwd, user) with environment variables to be sent over SSH, defined in the host alias. You can then keep same key pair and wrapper script across all projects. The script has to be updated to take these env vars, and possibly the host’s sshd daemon has to be reconfigured to allow forwarding them. Claude explained the specific how-to to me at some point, but I haven’t set it up myself yet, and I don’t want to publish something I haven’t tested end-to-end. I might try this when I need it.