Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Applications Difficulty: Intermediate

code-server: VS Code in the Browser on Your Own Server

code-server 4.141.0 behind Traefik: two locks in front, a custom image with a real toolchain, persistent Open VSX extensions and hard resource limits.

· 11 min read ·Duration: approx. 60 minutes
Table of contents

VS Code in the browser, from a tablet, from someone else’s machine, with no local setup – that is the tempting part. The uncomfortable one: whoever logs in gets a terminal on your server. Which is exactly why this tutorial is half a security tutorial.

What are we building?

We run code-server 4.141.0 (based on VS Code 1.141.0) behind Traefik, reachable over HTTPS and protected by two independent locks: BasicAuth in Traefik and code-server’s own password. On top of that we build a custom image with the toolchain you actually need – because the official one ships none.

At the end you can edit a file in the browser, run it in the integrated terminal and fetch the result. Extensions survive a restart, the container runs without a single capability and cannot grant itself root inside.

What you are really opening up

code-server has an integrated terminal. Whoever gets in can do anything the container user is allowed to do – read files, pull packages, open outbound connections. A single password in front of that is not enough. Treat this access like SSH access, not like a web dashboard.

Requirements

  • Traefik as a reverse proxy with a working Let’s Encrypt resolver and a domain pointing at the server
  • BasicAuth from the Traefik hardening tutorial – we use that middleware directly here
  • At least 2 GB of free memory. With one file open and the Python extension active the container needs 500 to 650 MB, more under load – we set the limit to 2 GB
  • Roughly 6 GB of disk: the base image is 1.51 GB, our extended one 1.99 GB
🍳 Recommendation Ad

VPS 2000 G12.5

8 vCores · 16 GB RAM · 256 GB SSD

from €26.92/month

An editor plus language servers plus your own project – the bigger VPS earns its keep here.

Go to netcup →

💶 1 month free:

Single use, valid for VPS 2000 G12.5. Redeem in the cart →

💶 €5.95 voucher for new netcup customers: always valid · not for domains or VPS Lite

All netcup vouchers →

Step by step

Step 1: Create the directories and generate the password hash

code-server runs in the container as the user with UID 1000. For it to write into the mounted directories, they have to belong to that user:

Terminal
mkdir -p /opt/code-server/{config,local,projekte}
chown -R 1000:1000 /opt/code-server
cd /opt/code-server

The three directories have clearly separated jobs: config holds the configuration, local the installed extensions and editor state, projekte your code. Only the third one contains things you cannot recreate.

Now the password hash. code-server understands Argon2 hashes, and Debian ships the right tool:

Terminal
apt-get install -y argon2
echo -n "YOUR_CODE_SERVER_PASSWORD" | argon2 "$(openssl rand -base64 12)" -e -id -k 65540 -t 3 -p 4
Ausgabe
$argon2id$v=19$m=65540,t=3,p=4$XXXXXXXXXXXXXXXXXXXXXXXX$XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

openssl rand supplies the salt, -id selects the recommended Argon2id variant, and -k 65540 is roughly 64 MB of memory cost per verification. That makes guessing expensive.

Note

The code-server documentation suggests npx argon2-cli. That package is now marked deprecated and points at your distribution’s tool itself – so use argon2 from the package manager.

You put the hash into code-server’s own configuration file, not into an environment variable:

YAML
# /opt/code-server/config/code-server/config.yaml
bind-addr: 0.0.0.0:8080
auth: password
hashed-password: "$argon2id$v=19$m=65540,t=3,p=4$YOUR$HASH"
cert: false
disable-telemetry: true
disable-update-check: true
disable-workspace-trust: true
Terminal
mkdir -p config/code-server   # put the file there
chown -R 1000:1000 config

Why the file and not environment:? Because Docker Compose reads every $ in environment values as the start of a variable and shreds your hash – a mistake you only notice at the login screen. Details below under “When it doesn’t work”. cert: false is correct because Traefik handles TLS. disable-workspace-trust saves you the “Do you trust the authors?” prompt for every folder – on a server that only holds your own code, that is pure clicking.

Step 2: A custom image, because the official one is empty

This is the point where most guides stop and the trouble starts. Look at what the official image actually contains:

Terminal
docker run --rm --entrypoint sh codercom/code-server:4.141.0 \
  -c 'for c in git curl python3 node gcc make; do printf "%-8s %s\n" $c "$(command -v $c || echo MISSING)"; done'
Ausgabe
git      /usr/bin/git
curl     /usr/bin/curl
python3  MISSING
node     MISSING
gcc      MISSING
make     MISSING

The image contains the editor and nothing else. A .py file gets syntax highlighting, but python3 app.py in the terminal answers bash: python3: command not found. So we build a derived image with exactly the toolchain you need:

Dockerfile
# /opt/code-server/Dockerfile
FROM codercom/code-server:4.141.0

USER root
RUN apt-get update \
 && apt-get install -y --no-install-recommends \
      python3 python3-venv python3-pip build-essential jq \
 && rm -rf /var/lib/apt/lists/*
USER coder

Swap the package list for whatever you work with – nodejs npm for JavaScript, golang-go for Go. The trailing USER coder matters: without it the editor starts as root.

Step 3: The Compose stack

First generate the BasicAuth hash for Traefik:

Terminal
docker run --rm httpd:2.4-alpine htpasswd -nbB YOUR_USER YOUR_BASICAUTH_PASSWORD
Ausgabe
YOUR_USER:$2y$05$XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Warning

In the Compose file you have to double every $ in the hash ($$). Otherwise Compose reads $2y as a variable, substitutes nothing and hands Traefik a broken hash – the login then fails for no visible reason.
YAML
# /opt/code-server/compose.yaml
services:
  code-server:
    build: .
    image: serverkueche/code-server:4.141.0
    container_name: code-server
    restart: unless-stopped
    user: "1000:1000"
    environment:
      TZ: Europe/Berlin
    volumes:
      - ./config:/home/coder/.config
      - ./local:/home/coder/.local
      - ./projekte:/home/coder/projekte
    command: ["/home/coder/projekte"]
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true
    mem_limit: 2g
    pids_limit: 512
    networks: [proxy]
    labels:
      - traefik.enable=true
      - traefik.http.routers.code.rule=Host(`YOUR_DOMAIN`)
      - traefik.http.routers.code.entrypoints=websecure
      - traefik.http.routers.code.tls.certresolver=le
      - traefik.http.routers.code.middlewares=code-auth@docker
      - traefik.http.middlewares.code-auth.basicauth.users=YOUR_USER:$$2y$$05$$YOUR$$HASH
      - traefik.http.services.code.loadbalancer.server.port=8080

networks:
  proxy:
    external: true

Four lines deserve an explanation, because they do more here than usual:

  • user: "1000:1000" – the editor does not run as root, and the files under projekte end up belonging to you instead of root.
  • cap_drop: [ALL] – code-server needs not a single Linux capability. Editor, terminal and extensions all work with this in place.
  • no-new-privileges:true – the sharpest line. The official image ships /etc/sudoers.d/nopasswd containing coder ALL=(ALL) NOPASSWD:ALL; without this flag anyone who reaches the terminal is one sudo away from root inside the container. With it, sudo fails cleanly: sudo: The "no new privileges" flag is set, which prevents sudo from running as root.
  • mem_limit: 2g / pids_limit: 512 – a runaway language server or an endless loop in the terminal should not take the server down with it.

Never mount the Docker socket

Plenty of examples online mount /var/run/docker.sock into code-server so you can start containers from the editor. That socket is equivalent to root on the host – it undoes every bit of hardening in this setup. If you want to build containers, do it over SSH or in a separate environment made for it.

Step 4: Start it and check both locks

Terminal
cd /opt/code-server && docker compose up -d --build
docker compose logs --tail 6
Ausgabe
info  code-server 4.141.0 8b41915d25547e2edd553bab03ea4cbd621f98c5
info  Using user-data-dir /home/coder/.local/share/code-server
info  Using config file /home/coder/.config/code-server/config.yaml
info  HTTP server listening on http://0.0.0.0:8080/
info    - Authentication is enabled
info      - Using hashed-password from /home/coder/.config/code-server/config.yaml
info    - Not serving HTTPS

Lines five and six confirm the hash was read. Not serving HTTPS is correct – Traefik does that.

Now the counter-test from another machine. Without credentials you do not even reach the login form:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" https://YOUR_DOMAIN/
Ausgabe
401

With BasicAuth, code-server redirects you to its own login:

Terminal
curl -s -o /dev/null -w "%{http_code}\n" -u YOUR_USER:YOUR_BASICAUTH_PASSWORD https://YOUR_DOMAIN/
Ausgabe
302

That is exactly right: two hurdles, two different passwords. In the browser you see code-server’s login after the BasicAuth dialog:

The code-server login page titled “Welcome to code-server” with a single password field.
The second lock: code-server's own password prompt

Warning

After logging in, code-server sets a cookie whose value is the password hash itself – with no expiry and without the Secure and HttpOnly protection flags. There is no server-side logout: whoever holds the cookie stays in until you change the password. So run it over HTTPS only, and clear browser storage afterwards on machines that are not yours.

Step 5: Working – file, terminal, port

After logging in you land directly in the working directory we passed via command:. Open a file with Ctrl+P:

The code-server editor with a Python file open, the file tree on the left and the detected Python version in the status bar.
The familiar interface – bottom right, the Python extension picks up the interpreter from our image

The real test is the terminal – via the command palette (Ctrl+Shift+P) and “Terminal: Create New Terminal”. There you start your program and call it right away:

Terminal
cd rezepte-api && python3 app.py & sleep 2; curl -s localhost:8000/rezepte | jq .

The integrated terminal in code-server: a running Python server, a curl call and the formatted JSON response.
Edit, run, fetch – all in one browser window

VS Code detects the opened port automatically and offers it under the PORTS tab. You cannot reach those forwarded ports from outside through a reverse proxy though – they are meant for working inside the editor, not for publishing.

A sudo in the terminal is refused now, by the way. That is intentional: the tools you need are already in the image, and anything beyond that belongs in the Dockerfile – not in an ad-hoc installation that disappears at the next docker compose pull.

Step 6: Extensions from Open VSX

code-server does not use Microsoft’s marketplace but Open VSX. That is not a setting but a licensing matter: the official marketplace may only be used by Microsoft’s own products.

The extensions view in code-server showing search results for “python” from the Open VSX marketplace.
The Open VSX marketplace – most of the big extensions are there, a few are not

Day to day it is better than its reputation: Python, the debugger, Docker, GitLens and the common themes are all available. What is missing are the extensions Microsoft licenses exclusively for VS Code – above all Pylance, the C# extension and Remote Development. For Python, ms-python.python steps in with the open language server.

Extensions can also be installed from the command line, which is worth it for a reproducible setup:

Terminal
docker exec code-server code-server --install-extension ms-python.python
Ausgabe
Extension 'ms-python.vscode-python-envs' v1.38.0 was successfully installed.
Extension 'ms-python.debugpy' v2026.6.0 was successfully installed.
Extension 'ms-python.python' v2026.4.0 was successfully installed.

They land in the local volume and therefore survive restarts and updates:

Terminal
docker compose restart && sleep 10
docker exec code-server code-server --list-extensions
Ausgabe
ms-python.debugpy
ms-python.python
ms-python.vscode-python-envs

When it doesn’t work

The login fails with the correct password even though the hash is right. Then the hash is probably in a .env file or under environment:. Docker Compose interpolates $ characters in values from env files too, turning $argon2id$v=19$m=65540,... into =19=65540,t=3,p=4/Ntxaqbs... – and it only warns with The "argon2id" variable is not set. Either double every $ to $$, or do it the way we did and put the hash in config.yaml, where Compose never looks.

Traefik answers with 401 even though you typed the right BasicAuth password. Same cause, different place: every $ of the bcrypt hash in the basicauth.users label has to be doubled. Check what actually arrives with docker compose config – a mangled hash there means the doubling is missing.

The terminal says bash: python3: command not found. The official image contains only the editor, no language runtimes – git, curl and sudo are there, nothing more. Build the derived image from step 2 and restart with docker compose up -d --build. Installing into the running container only helps until the next restart.

code-server cannot save a file and reports Permission denied. The folders under projekte belong to root instead of UID 1000. That happens as soon as you create a directory as root on the host. chown -R 1000:1000 /opt/code-server/projekte fixes it; the habit worth building is to create new projects from inside the editor rather than on the host.

sudo in the terminal aborts with The "no new privileges" flag is set. That is not a defect but the effect of security_opt: no-new-privileges:true. Do not remove that line – it is the only reason the bundled coder ALL=(ALL) NOPASSWD:ALL in /etc/sudoers.d/nopasswd is useless to anyone. Whatever you need permanently belongs in the Dockerfile.

Your problem is not listed? Search all error messages →

Maintenance & backups

Updates go through the image tag, and because we build ourselves it takes two steps:

Terminal
cd /opt/code-server
sed -i 's/code-server:4.141.0/code-server:NEW_VERSION/g' Dockerfile compose.yaml
docker compose up -d --build
docker exec code-server code-server --version

code-server tracks VS Code closely and releases often – roughly a new minor version every month. You do not have to take every one; once a quarter is fine unless a security advisory shows up in between. Keep in mind that an update also refreshes the base image and with it the Debian packages from your Dockerfile – which is what you want.

Only one of the three directories genuinely needs backing up:

Ausgabe
/opt/code-server/projekte/   # your code – irreplaceable
/opt/code-server/config/     # config.yaml with the password hash
/opt/code-server/Dockerfile  # your toolchain
/opt/code-server/compose.yaml

local/ with the extensions is deliberately left out: it grows with every extension – with the Python extension it is around 60 MB – and is restored in two minutes by reinstalling your extension list. Keep that list as a file in the project instead – code-server --list-extensions > extensions.txt – and back that up. All of it belongs in your Restic backup.

Better still: use Git. The editor ships with it, and your code then exists twice by default. On your own Forgejo instance it stays in house while doing so.

Realistic effort: little, once it is set up – one update per quarter, a glance at memory use when the editor gets sluggish. The part that needs attention is not maintenance but access control: rotate both passwords when a device goes missing, and check the Traefik logs now and then for who has been trying the BasicAuth.

One step stricter

If two passwords are not enough for you, put code-server entirely inside the VPN and let Traefik only pass requests from the VPN subnet. The editor is then invisible from the internet altogether – the pattern for that is in making services reachable only over VPN.

You might also like