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:
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 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:
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --dry-run --version=4.143.1
Then install the pinned release:
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:
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.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:
~/.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.
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:
mkdir -p ~/projects cd ~/projects
Clone a repository you are authorized to access. For a harmless public test:
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:
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:
~/.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:
cd ~/projects/Hello-World git status --ignored
Review permissions on SSH/config directories if you use them:
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:
mkdir -p ~/backups chmod 700 ~/backups STAMP="$(date +%F-%H%M%S)"
Archive code-server configuration and editor state:
tar -czf "$HOME/backups/code-server-state-$STAMP.tar.gz" \ -C "$HOME" \ .config/code-server \ .local/share/code-server
List the archive:
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:
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:
curl -fsSL https://code-server.dev/install.sh | \ sh -s -- --version=4.143.1
After a future upgrade, restart and verify:
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:
sudo apt update apt list --upgradable 2>/dev/null | grep -i tailscale || true
After upgrading Tailscale, confirm that Serve resumed:
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:
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:
- the
*.ts.netHTTPS URL opens while Tailscale is connected; - code-server still requires its password;
- a workspace and terminal work normally;
- disconnecting the client from Tailscale makes the private Serve URL unreachable;
- the VM's public IP on port 8080 does not expose code-server.
To disable only the private Serve endpoint:
sudo tailscale serve reset
To stop code-server:
sudo systemctl disable --now code-server@$USER
To leave the tailnet when this VM no longer needs Tailscale:
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:
sudo journalctl -u code-server@$USER -n 120 --no-pager
Then verify the YAML and port:
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:
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:
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:
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.