How to Build a Remote Development VM with code-server and Tailscale

Serdar Tekin
Serdar Tekin
Co-Founder & CTO
IntermediateUpdated Oct 27, 202620 min read~45+ minutes total
code-serverTailscaleRemote DevelopmentUbuntu 24.04Browser IDEVS CodeSecurityDevOps
Last reviewed:
Tutorial
How to Build a Remote Development VM with code-server and Tailscale
rafftechnologies.com/learn
On this page

Prerequisites

  • An Ubuntu 24.04 VM with SSH and sudo access, a non-root development user, a Tailscale account/tailnet, a second Tailscale-capable client device, and resources sized for your actual development workload

Don’t have a server yet?

A remote development VM can give you a persistent Linux workspace that you access from a browser without publishing your editor to the public internet. In this setup, code-server stays bound to 127.0.0.1:8080, Tailscale connects the VM to your private tailnet, and Tailscale Serve terminates HTTPS and forwards only tailnet-authorized traffic to the local code-server process.

The original tutorial used a Raff Linux VM as the Ubuntu host. The saved test environment is Ubuntu 24.04 LTS on a Raff 2 vCPU / 4 GB RAM VM. This update retains the original test record, while the pinned code-server release was reviewed against upstream versions on October 27, 2026. No new full VM test is claimed. It pins code-server 4.143.1, keeps code-server's own password authentication as a second layer behind Tailscale, uses the current tailscale serve --bg syntax, and avoids stale claims about hourly billing, unlimited bandwidth, or fixed VM sizing.

This architecture is intentionally private. You do not need a public domain, Let's Encrypt, Nginx, Caddy, or an inbound firewall rule for port 8080. Your browser connects to the VM's private *.ts.net HTTPS URL through Tailscale, while code-server remains a loopback-only service. Tailscale access controls still apply to Serve, so a team can further restrict which tailnet users or devices may reach the development VM.

Prerequisites:

  • An Ubuntu 24.04 VM with SSH and sudo access
  • A non-root Linux user for daily development
  • A Tailscale account/tailnet and a second device that can join it
  • Enough CPU, RAM, and disk for your actual development workload

Step 1 — Verify Ubuntu, SSH access, resources, and existing listeners

Confirm the operating system:

Bash
cat /etc/os-release

Check memory and disk space:

Bash
free -h df -h /

Check whether anything is already listening on code-server's default port:

Bash
sudo ss -ltnp | grep ':8080' || true

Keep your current SSH session open while you configure Tailscale and code-server. If you later change firewall or SSH policy, a working fallback session reduces lockout risk.

Do not treat the original 2 vCPU / 4 GB test VM as a universal sizing rule. A Node.js project, a large monorepo, local containers, compilers, language servers, and browser tabs can have very different resource requirements.

Verify: Ubuntu should report 24.04, the VM should have enough free resources for your workload, and you should know whether port 8080 is already occupied before continuing.

Step 2 — Install the baseline development utilities

Update package metadata and install the tools used by this tutorial:

Bash
sudo apt update sudo apt install -y \ git curl ca-certificates openssl build-essential unzip

Check the tools:

Bash
git --version curl --version | head -1 openssl version

A full unattended apt upgrade -y is not required just to install code-server. Apply normal OS security updates according to your server maintenance policy, especially if the VM already runs other workloads.

For a fresh server, complete your normal SSH and host-hardening baseline before making it a daily development machine. See Secure Ubuntu 24.04 Server.

Verify: Git, curl, and OpenSSL should return installed versions without errors.

Step 3 — Install a pinned code-server release

The code-server project documents its official install.sh as the easiest Linux installation method and supports an exact --version argument. The currently published stable release verified on October 27, 2026 is 4.143.1. Version 4.141.1 included security updates, so avoid older releases.

Preview what the installer will do:

Bash
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --dry-run --version=4.143.1

Then install the pinned release:

Bash
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --version=4.143.1

Enable and start the packaged systemd service for your current user:

Bash
sudo systemctl enable --now code-server@$USER

Check the installed version and service:

Bash
code-server --version sudo systemctl is-active code-server@$USER

The official Debian/Ubuntu installation flow runs code-server through the [email protected] template. Do not confuse that with a separate systemctl --user unit unless you deliberately created one yourself.

Verify: code-server --version should report 4.143.1 for this revision and code-server@$USER should be active.

Step 4 — Keep code-server on localhost with its own password authentication

code-server's default configuration lives at:

Text
~/.config/code-server/config.yaml

Its secure default pattern is loopback binding plus password authentication. This tutorial keeps both instead of using Tailscale as the only authentication layer.

Generate a new random password and write a restrictive config:

Bash
umask 077 CODE_SERVER_PASSWORD="$(openssl rand -base64 24 | tr -d '\n')" mkdir -p ~/.config/code-server cat > ~/.config/code-server/config.yaml <<EOF bind-addr: 127.0.0.1:8080 auth: password password: "${CODE_SERVER_PASSWORD}" cert: false EOF unset CODE_SERVER_PASSWORD chmod 600 ~/.config/code-server/config.yaml

Restart the service:

Bash
sudo systemctl restart code-server@$USER

Treat config.yaml as a secret because it contains the password in this configuration. code-server also supports hashed-password if you want to manage an Argon2 hash instead, but adding a hashing tool is not required for this private baseline.

Tailscale has examples that disable code-server's password when tailnet identity is the sole authorization boundary. This tutorial deliberately keeps the password enabled for defense in depth.

Verify:

Bash
sudo systemctl is-active code-server@$USER stat -c '%a %n' ~/.config/code-server/config.yaml grep -E '^(bind-addr|auth|cert):' ~/.config/code-server/config.yaml

The file mode should be 600, the bind address should be 127.0.0.1:8080, and authentication should remain password.

Step 5 — Verify code-server locally before adding Tailscale

Test the local HTTP endpoint:

Bash
curl -I http://127.0.0.1:8080

Check the listener:

Bash
sudo ss -ltnp | grep ':8080'

The important part is the local address:

Text
127.0.0.1:8080

Do not change this to 0.0.0.0:8080 for the Tailscale Serve design. A loopback bind ensures devices on the public internet, your LAN, or even the tailnet cannot bypass Tailscale Serve and connect directly to code-server's backend port.

If the service fails, inspect logs:

Bash
sudo journalctl -u code-server@$USER -n 100 --no-pager

Verify: code-server should answer on 127.0.0.1:8080 and should not be listening on 0.0.0.0:8080 or the VM's public IP.

Step 6 — Install Tailscale and join the VM to your tailnet

Install Tailscale using its official Linux installer:

Bash
curl -fsSL https://tailscale.com/install.sh | sh

Join the tailnet:

Bash
sudo tailscale up

The command normally prints an authentication URL. Open it, authenticate with your Tailscale identity provider, and authorize the VM when required by your tailnet policy.

Check the connection:

Bash
tailscale status tailscale ip -4 tailscale version

For a long-lived server, be aware of Tailscale device key expiry. Leaving expiry enabled gives you periodic reauthentication; disabling it for a trusted server avoids expiry-related interruptions but increases the lifetime of that device's authorization. Choose deliberately in the Tailscale admin console rather than assuming one policy is right for every development VM.

Verify: The VM should appear online in tailscale status and should have a 100.x.y.z Tailscale IPv4 address.

Step 7 — Verify your client device can reach the VM through Tailscale

Install and sign in to Tailscale on the laptop, tablet, or other device you will use to access code-server.

From the VM, inspect its tailnet hostname:

Bash
tailscale status --self

From the client device, confirm the VM appears in Tailscale. If your client has the Tailscale CLI, test reachability:

Bash
tailscale ping <vm-tailnet-name>

Tailscale may route a connection directly peer-to-peer or through a relay depending on NAT/firewall conditions. Either path can be valid; do not use the transport path alone as a health verdict.

If your tailnet uses custom access controls, make sure your intended user/device is allowed to reach the VM. Tailscale Serve inherits the tailnet's access-control policy.

Verify: The client should see the VM as an authorized tailnet device and should be able to reach it according to your tailnet policy.

Step 8 — Publish localhost:8080 privately with Tailscale Serve

Create a persistent private HTTPS reverse proxy:

Bash
sudo tailscale serve --bg localhost:8080

Check the active Serve configuration:

Bash
sudo tailscale serve status

Current Tailscale documentation states that --bg keeps the Serve configuration active across device reboots and Tailscale restarts. The HTTPS endpoint is restricted to your tailnet; Tailscale Serve is not Tailscale Funnel.

If HTTPS certificates are not yet enabled for your tailnet, the CLI may give you a consent/setup URL. Complete that one-time tailnet configuration and rerun the command if necessary.

Tailscale Serve terminates HTTPS and proxies to the local HTTP backend. code-server itself can therefore keep cert: false on loopback.

Do not run tailscale funnel for this tutorial. Funnel is intended for public internet exposure, which would change the security model.

Verify: sudo tailscale serve status should show an HTTPS *.ts.net URL forwarding to http://localhost:8080.

Step 9 — Sign in through the private HTTPS URL and verify browser features

From a device that is signed into the authorized tailnet, open the HTTPS URL shown by:

Bash
sudo tailscale serve status

Retrieve the code-server password on the VM when needed:

Bash
awk -F': ' '/^password:/ {print $2}' ~/.config/code-server/config.yaml

Use that password to sign in.

After login, verify more than the landing page:

  • open the Explorer panel;
  • open an integrated terminal;
  • create and save a test file;
  • reconnect after refreshing the page;
  • open a second tab and confirm the editor remains responsive.

code-server depends on WebSockets for normal browser/editor communication. Tailscale Serve's HTTP reverse proxy handles the HTTPS frontend while forwarding to the local service, so you do not need an Nginx-style WebSocket header block here.

Verify: The *.ts.net HTTPS URL should load only from an authorized tailnet device, code-server should request its password, and the integrated terminal/file editor should work after login.

Step 10 — Create a clean workspace and understand code-server extensions

Create a projects directory:

Bash
mkdir -p ~/projects cd ~/projects

Clone a repository you are authorized to access. For a harmless public test:

Bash
git clone https://github.com/octocat/Hello-World.git cd Hello-World git status

Open ~/projects/Hello-World in code-server.

You can install extensions from the Extensions view or CLI:

Bash
code-server --install-extension <extension-id>

One important difference from Microsoft's desktop VS Code is the marketplace. Current code-server documentation states that it cannot use Microsoft's extension marketplace under Microsoft's marketplace terms; code-server uses Open VSX / compatible galleries instead. Some Microsoft-only or closed-source extensions may therefore be unavailable or behave differently.

Extensions are stored under:

Text
~/.local/share/code-server/extensions

Editor settings and related state are also stored under ~/.local/share/code-server by default.

Verify: You should be able to open the cloned workspace, run git status from the integrated terminal, save a file, and install at least one extension that is available through code-server's supported extension gallery.

Step 11 — Protect source code, Git credentials, and unpushed work

A remote development VM often contains more sensitive material than a generic test server: source repositories, .env files, package-registry tokens, SSH agent access, Git credentials, and unpushed commits.

Prefer short-lived or scoped credentials where your Git/package provider supports them. Avoid casually copying your laptop's long-lived private SSH key into the VM. If you use SSH-agent or GPG-agent forwarding, understand that a compromised remote account may be able to use the forwarded agent while the session exists.

Keep project secrets out of Git:

Bash
cd ~/projects/Hello-World git status --ignored

Review permissions on SSH/config directories if you use them:

Bash
ls -ld ~/.ssh ~/.config/code-server 2>/dev/null || true

For team access, use separate Linux/tailnet identities rather than sharing one code-server password broadly. code-server itself is primarily a single-user tool; the Coder project recommends VM/workspace isolation when multiple users need independent environments.

Verify: Sensitive .env/key material should not be tracked unintentionally, and the development VM should use credentials appropriate for a remote machine rather than a copy of every local secret.

Step 12 — Back up code-server state and preserve source repositories correctly

Treat Git remotes as the primary source backup for repositories: push commits you cannot afford to lose. Then protect the editor state and any non-Git workspace files separately.

Create a protected backup directory:

Bash
mkdir -p ~/backups chmod 700 ~/backups STAMP="$(date +%F-%H%M%S)"

Archive code-server configuration and editor state:

Bash
tar -czf "$HOME/backups/code-server-state-$STAMP.tar.gz" \ -C "$HOME" \ .config/code-server \ .local/share/code-server

List the archive:

Bash
tar -tzf "$HOME/backups/code-server-state-$STAMP.tar.gz" | head

Do not blindly archive entire development trees full of node_modules, build artifacts, caches, databases, or secrets. Back up non-Git project data intentionally according to what the project actually contains.

Keep at least one recovery copy away from the VM. Raff Data Protection can provide an additional infrastructure-level recovery layer, but repository pushes and application-aware backups are still important for active development work.

Verify: Important repositories should have their needed commits on a remote Git service, and the code-server state archive should be readable without errors.

Step 13 — Update code-server and Tailscale without losing the private-access model

Before upgrading, check the current versions:

Bash
code-server --version tailscale version

For code-server, review the project's release notes and install the exact target version you chose. For example, this revision's pinned version can be reinstalled with:

Bash
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --version=4.143.1

After a future upgrade, restart and verify:

Bash
sudo systemctl restart code-server@$USER sudo systemctl is-active code-server@$USER sudo ss -ltnp | grep ':8080'

For Tailscale installed from its official package source, use your normal APT update workflow:

Bash
sudo apt update apt list --upgradable 2>/dev/null | grep -i tailscale || true

After upgrading Tailscale, confirm that Serve resumed:

Bash
tailscale status sudo tailscale serve status

The security invariants should survive every update: code-server remains on 127.0.0.1:8080, Serve remains private to the tailnet, and Funnel remains unused.

Verify: After updates/restarts, the browser should still use the private HTTPS Serve URL and ss should still show code-server bound only to loopback.

Step 14 — Run the final security check and roll back safely

Run the final server-side checks:

Bash
sudo systemctl is-active code-server@$USER tailscale status tailscale ip -4 sudo tailscale serve status sudo ss -ltnp | grep ':8080' stat -c '%a %n' ~/.config/code-server/config.yaml

From the client, confirm:

  1. the *.ts.net HTTPS URL opens while Tailscale is connected;
  2. code-server still requires its password;
  3. a workspace and terminal work normally;
  4. disconnecting the client from Tailscale makes the private Serve URL unreachable;
  5. the VM's public IP on port 8080 does not expose code-server.

To disable only the private Serve endpoint:

Bash
sudo tailscale serve reset

To stop code-server:

Bash
sudo systemctl disable --now code-server@$USER

To leave the tailnet when this VM no longer needs Tailscale:

Bash
sudo tailscale down

Do not remove project files, editor state, Git credentials, or backups until you have verified what needs to be retained. If you later uninstall code-server, follow the uninstall path matching the installation method reported by the official installer.

Verify: After Serve reset, the private HTTPS URL should stop serving code-server while SSH/admin access remains intact. If you re-enable the services, the original private architecture should return without opening public port 8080.

Troubleshooting

code-server@$USER is inactive

Inspect the service log:

Bash
sudo journalctl -u code-server@$USER -n 120 --no-pager

Then verify the YAML and port:

Bash
cat ~/.config/code-server/config.yaml sudo ss -ltnp | grep ':8080' || true

A malformed config or another process already using port 8080 will prevent a normal start.

Tailscale Serve shows a URL but the browser cannot connect

Check the VM:

Bash
tailscale status sudo tailscale serve status curl -I http://127.0.0.1:8080

Then confirm the client device is logged into the same tailnet and permitted by your tailnet access controls. Serve does not make the service public.

Tailscale asks you to enable HTTPS certificates

Follow the consent/setup URL provided by the CLI. Serve HTTPS requires certificate support for the tailnet's *.ts.net names. After enabling it, rerun:

Bash
sudo tailscale serve --bg localhost:8080

The browser works but some editor actions disconnect

Confirm the client is not aggressively blocking WebSockets and that you are using the Serve HTTPS URL rather than manually proxying code-server through another web server. Inspect code-server logs while reproducing the problem:

Bash
sudo journalctl -fu code-server@$USER

An extension is missing from the marketplace

code-server does not use Microsoft's official VS Code marketplace by default/under Microsoft's marketplace terms. Search Open VSX or the supported gallery, or install a trusted .vsix obtained from the extension project's official release channel when licensing and compatibility allow it.

The VM works through Tailscale but public SSH is still open

That is not automatically a code-server problem. Decide your SSH exposure separately. If you later move SSH to Tailscale-only access, test Tailscale SSH or normal SSH over the tailnet first and keep a provider console/recovery path before removing the existing public SSH rule.

Conclusion

You now have a remote development VM on Ubuntu 24.04 with code-server kept on 127.0.0.1:8080 and a private Tailscale Serve HTTPS endpoint for browser access. The setup keeps code-server password authentication enabled, respects tailnet access controls, avoids Tailscale Funnel, and does not require public port 8080, a public domain, or a separate reverse proxy.

The workflow also accounts for code-server's supported extension marketplace, persistent editor state, Git/secrets hygiene, upgrades, backups, and rollback. The original Raff 2 vCPU / 4 GB test environment is preserved as historical test metadata; choose production resources from the actual languages, repositories, containers, compilers, and concurrency your development workflow uses.

For adjacent access controls, see Configure SSH Keys on Ubuntu and Set Up UFW Firewall on Ubuntu 24.04. The wider Ubuntu hardening guide is linked alongside the setup steps.

Deploy a Raff Linux VM for a private code-server workspace using Tailscale Serve. Keep code-server bound to localhost and retain a tested recovery path.

Was this article helpful?
Serdar Tekin
Serdar Tekin
Co-Founder & CTO

Co-Founder & CTO at Raff, focused on cloud infrastructure, systems architecture, security, reliability, and production operations.

Put it into practice

Spin up a Raff server, follow the steps above on real infrastructure, and you are live. Linux or Windows, backed by a 14-day money-back guarantee.

Frequently asked questions

How do I run code-server privately on Ubuntu?
Keep code-server bound to `127.0.0.1:8080`, join the VM to Tailscale, then use `tailscale serve --bg localhost:8080` to provide a private HTTPS URL to authorized devices in your tailnet.
Should code-server listen on 0.0.0.0 when using Tailscale Serve?
No for this architecture. Leaving code-server on loopback prevents clients from bypassing Tailscale Serve and connecting directly to port 8080 over the public, LAN, or tailnet interfaces.
Do I need a public domain or Let's Encrypt for code-server with Tailscale?
No. Tailscale Serve can provide HTTPS on the device's private `*.ts.net` name after HTTPS certificates are enabled for the tailnet.
Should I disable the code-server password when using Tailscale?
Tailscale documents configurations where tailnet identity is the only authorization layer, but this tutorial keeps code-server password authentication enabled as defense in depth. Do not set `auth: none` unless you deliberately want the tailnet policy to be the sole access boundary.
Is Tailscale Serve public on the internet?
No. Serve shares the service inside your tailnet and respects Tailscale access controls. Tailscale Funnel is the separate feature for public internet exposure and is intentionally not used here.
Which code-server version does this tutorial use?
As of October 27, 2026, this guide pins code-server 4.143.1, released October 23. Upstream's October security updates are included in that version. The updated release has not been retested on a new Raff VM.
Why are some VS Code extensions missing in code-server?
code-server cannot use Microsoft's official extension marketplace under Microsoft's marketplace terms. It uses Open VSX or compatible galleries, so some Microsoft-only or closed-source extensions may be unavailable or require a separately obtained compatible VSIX.
What should I back up on a remote development VM?
Push important source commits to a Git remote, then separately protect code-server configuration/editor state and any non-Git project data. Do not assume generated build folders, package caches, secrets, and source repositories all need the same backup strategy.