VS Code on a headless coding VM, via code-server and an SSH tunnel
I do my coding on a headless VM. The machine in front of me is just a remote
terminal: I ssh into the VM and do everything there, in tmux, with Claude Code
and LazyVim. That works great for code, and it has
one big weak spot: reading Markdown. Claude produces a lot of Markdown
(plans, design docs, READMEs, AGENTS.md), and reading it as raw text in a
terminal is a pain. Tables come out as ASCII soup, there are no images, and I
can’t click links.
I tried a few remote Markdown viewers. Then it dawned on me: VS Code has a perfectly good Markdown preview, and while I’m at it, VS Code is also a full-blown IDE. code-server runs VS Code on the VM and serves it to a browser. All I need is a browser on the terminal machine and an SSH tunnel.
The setup in one picture
terminal machine headless VM
┌────────────────────────┐ ssh -L 5000 ┌──────────────────────────────┐
│ browser │ ════════════▶ │ code-server │
│ http://localhost:5000 │ │ listening on 127.0.0.1:5000 │
└────────────────────────┘ └──────────────────────────────┘
code-server listens only on the VM’s loopback, so nothing on the network can reach it. The SSH tunnel is the only way in. That’s why it doesn’t need a password.
Install code-server on the VM
I followed the Debian/Ubuntu
instructions: grab
the .deb from the GitHub releases, install it, and enable the systemd unit
the package ships:
VERSION=4.137.0
curl -fOL https://github.com/coder/code-server/releases/download/v$VERSION/code-server_${VERSION}_amd64.deb
sudo dpkg -i code-server_${VERSION}_amd64.deb
sudo systemctl enable --now code-server@$USER
code-server@.service is a template unit, so code-server@mavi runs
code-server as the user mavi, starts it at boot and restarts it if it dies.
One thing to know: the .deb doesn’t add an apt repository, so
apt upgrade won’t update it. To upgrade, install a newer .deb the same way.
Configure it: localhost only, no password
The config file is ~/.config/code-server/config.yaml. On first start
code-server generates one with a random password. Mine looks like this:
bind-addr: 127.0.0.1:5000
auth: none
cert: false
bind-addr: 127.0.0.1:5000binds to loopback only, so the VM’s network interfaces don’t expose it at all.auth: noneturns off the password page. The only way to reach the port is through SSH, and SSH already authenticated me. A second password adds nothing. (This assumes the VM is single-user: any other local user or process on the VM can also reach the port.)cert: falsemeans plain HTTP. There’s no need for TLS, because the tunnel already encrypts the traffic.
Restart it after editing the config:
sudo systemctl restart code-server@$USER
Tunnel it to the terminal machine
On the terminal machine:
ssh vm -L 5000:localhost:5000
Then open http://localhost:5000 in the browser. That’s the whole trick: the full VS Code, with the VM’s filesystem, terminals, git and language servers, in a browser tab.
I don’t pass -N, so the same ssh session also gives me a shell. My
tmux+Claude workflow stays where it was, and VS Code sits next to it. To make
the tunnel automatic, add it to ~/.ssh/config on the terminal machine:
Host vm
LocalForward 5000 localhost:5000
Why it has to be localhost
Don’t be tempted to skip the tunnel and open http://<vm-ip>:5000 directly
(with bind-addr: 0.0.0.0). Some browser features only work in a secure
context, which means either HTTPS or localhost/127.0.0.1. Plain HTTP to
an IP address is not a secure context, so the browser disables things like:
- Clipboard access. Copy/paste between VS Code and the rest of your desktop stops working properly.
- Service workers. VS Code webviews depend on them, and per the code-server FAQ, webviews break without a secure context. The Markdown preview is a webview, so you’d lose exactly the feature I came here for.
Through the tunnel the browser sees localhost, which counts as secure, so
everything works without dealing with certificates.
Make it feel like LazyVim
My muscle memory is LazyVim’s, so I made VS Code follow it. Install the VSCodeVim extension. Note that code-server uses the Open VSX gallery, not Microsoft’s marketplace. Most popular extensions are there, including Claude Code, Red Hat’s Java support and Ruby LSP; the closed-source Microsoft ones, like Live Share and the Remote-* family, are not.
Then map the LazyVim keys onto VS Code commands in the user settings. In
code-server those live at ~/.local/share/code-server/User/settings.json on the
VM, not in ~/.config/Code. Download my
settings.json and save it there.
It maps <Space> as leader, <Space><Space> / <Space>ff for file search,
<Space>/ for grep, H/L to cycle editors, <Space>c* for code actions,
]d/[d for diagnostics, ]h/[h for git hunks, and so on. Here’s a taste:
{
"vim.leader": "<space>",
"vim.normalModeKeyBindingsNonRecursive": [
{ "before": ["<leader>", "<leader>"], "commands": ["workbench.action.quickOpen"] },
{ "before": ["<leader>", "/"], "commands": ["workbench.action.findInFiles"] },
{ "before": ["<S-h>"], "commands": ["workbench.action.previousEditor"] },
{ "before": ["<S-l>"], "commands": ["workbench.action.nextEditor"] },
{ "before": ["<leader>", "c", "a"], "commands": ["editor.action.quickFix"] },
{ "before": ["]", "h"], "commands": ["workbench.action.editor.nextChange"] },
// ...and about 40 more
]
}
A few notes:
vim.handleKeyshandsCtrl+B,Ctrl+PandCtrl+Cback to VS Code, so the sidebar toggle, quick open and copy keep working.- VS Code has no real flash.vim, so
sfalls back to VSCodeVim’s built-in easymotion search. - VSCodeVim can’t do exactly what LazyVim does when a mapping is a prefix of a
longer one (
<leader>wvs<leader>wd,<leader>cvs<leader>cfin visual mode). It waits briefly for the next key, and that’s good enough for me.
Result
Nothing about the VM changed. It’s still headless, and tmux, Claude and LazyVim are still there for when I want them. I just gained a browser tab with a full IDE on the same files: rendered Markdown, a proper diff viewer, a debugger, and LazyVim-ish keybindings so my fingers don’t notice the switch. The terminal machine still needs nothing but ssh and a browser.