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.
Raff Technologies is the VM platform used by the original tutorial. The saved test environment is Ubuntu 24.04 LTS on a Raff 2 vCPU / 4 GB RAM VM. This revision keeps that tested-on record but updates the workflow against current upstream guidance on September 4, 2026. It pins code-server 4.133.0, 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:
cat /etc/os-release
Check memory and disk space:
free -h df -h /
Check whether anything is already listening on code-server's default port:
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:
sudo apt update sudo apt install -y \ git curl ca-certificates openssl build-essential unzip
Check the tools:
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 current stable release verified for this revision is 4.133.0.
Preview what the installer will do:
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --dry-run --version=4.133.0
Then install the pinned release:
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --version=4.133.0
Enable and start the packaged systemd service for your current user:
sudo systemctl enable --now code-server@$USER
Check the installed version and service:
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.133.0 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:
~/.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:
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:
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:
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:
curl -I http://127.0.0.1:8080
Check the listener:
sudo ss -ltnp | grep ':8080'
The important part is the local address:
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:
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:
curl -fsSL https://tailscale.com/install.sh | sh
Join the tailnet:
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:
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:
tailscale status --self
From the client device, confirm the VM appears in Tailscale. If your client has the Tailscale CLI, test reachability:
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:
sudo tailscale serve --bg localhost:8080
Check the active Serve configuration:
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:
sudo tailscale serve status
Retrieve the code-server password on the VM when needed:
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.