Can I self-host CodeSandbox?
YES · ONE COMMAND— setup effort 1 of 4YES — it's called code-server. It takes one prompt, a 1024 MB VPS, and about 10 minutes. That is $12 a month you stop paying CodeSandbox — $144 a year on the Pro plan.
Why people pay for CodeSandbox
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
CodeSandbox sells the machine you did not have to set up. A link opens a running development environment in seconds, a branch or a pull request gets its own copy, a template starts a project before you have installed anything, and the whole thing can be embedded in a document or handed to a stranger who has no toolchain at all. You are paying for provisioning, for the sandbox being disposable, and for somebody else keeping the VMs warm.
| Plan | List price | What it buys |
|---|---|---|
| Free | free | 400 VM credits a month and a cap on how many sandboxes a workspace keeps. |
| Prothe plan this page prices against | $12/mo | $9 a month on the annual plan. A base allowance of 1,000 VM credits a month; more are bought as add-ons, so the bill moves with how long the machines stay awake. |
| Enterprise | quote only | Quote only. |
Vendor list prices in USD, read from the pricing page on 2026-08-06 · confidence: medium
Replaced by code-server
One project, named before the prompt, so you know what you are about to install.
VS Code in a browser tab, running on a machine you own, with the terminal and the toolchain that come with it.
The closest thing to the editor half of CodeSandbox that you can run yourself: the same VS Code workbench, the same keybindings, the same terminal, reachable from any browser on a machine you control. What it does not reproduce is the part CodeSandbox actually charges for, which is a fresh disposable VM per branch and a link that boots one for someone else. This is one persistent environment that you maintain, and the honest trade is that you swap instant provisioning for never being metered.
The swap
You'd run
code-server
ONE COMMAND · ~10 min to running · 1024 MB RAM
CodeSandbox Pro · vendor list price · checked 2026-08-06 · source · confidence: medium
Before you start
- RAM floor
- 1024 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~10 minunder 10 minutes, through the first backup
The prompt
Two paths to the same code-server: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
325 lines · 14,902 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install code-server 4.131.0 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server.
Say two things to the user before anything installs. code-server is VS Code with a browser front
end, and VS Code comes with an integrated terminal, so this hostname becomes a shell on this
server behind one password; everything below treats that password accordingly. And the editor
opens an empty folder: nothing is copied here from their laptop, and there is nothing to browse
until they clone a repository.
code-server needs 1024 MB of RAM available and 10 GB free on /srv. Upstream's stated floor is
1 GB of RAM and 2 CPU cores; the 10 GB is the image, the extensions and whatever the user builds.
The image publishes amd64 and arm64. Measure all four:
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 1024 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot certify
a name that does not resolve.
## 2. Layout
Four directories and one configuration file. The container runs as uid 1000, so three of the four
belong to that uid rather than to the login user.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/code-server /srv/code-server/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/code-server/local /srv/code-server/project
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/code-server/config /srv/code-server/config/code-server
cat > /srv/code-server/config/code-server/config.yaml <<'EOF'
auth: password
disable-telemetry: true
disable-update-check: true
EOF
sudo chown -R 1000:1000 /srv/code-server/config
ls -la /srv/code-server
sudo cat /srv/code-server/config/code-server/config.yaml
```
Assert: `ls -la` shows `local`, `project` and `config` owned by uid `1000`, `backups` owned by
the login user, and the last command prints the three lines above. Upstream writes this file
itself on first start with a random password in it, and refuses to overwrite one that exists.
Writing it first keeps that second credential-shaped string off the disk and turns telemetry off
before a request leaves the box. It carries no `password` line: step 3 supplies that from the
environment, which wins either way.
## 3. Secrets
One secret: the password that stands between the internet and a terminal on this server.
Generate it here. Do not print it, do not repeat it in your summary, and do not put it in any
log line.
```bash
umask 077
cat > /srv/code-server/.env <<EOF
PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/code-server/.env
umask 022
ls -l /srv/code-server/.env
```
Assert: the file exists with mode `-rw-------`. Hex rather than base64, because Docker Compose
reads this same file for interpolation and a `$` in the value would be expanded. Sixty-four hex
characters is 256 bits, which is not overkill on a login that is also a shell.
Two facts worth saying to the user: code-server deletes the variable from its own environment
before starting anything, so the terminal the editor opens does not inherit it, and the login
route refuses more than 2 attempts a minute plus 12 an hour. Then tell them
`grep PASSWORD /srv/code-server/.env` reads the value and that it belongs in their password
manager now.
## 4. compose.yml
```bash
cat > /srv/code-server/compose.yml <<'EOF'
# code-server · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://coder.com/docs/code-server/install
# faq and config ..... https://coder.com/docs/code-server/FAQ
# requirements ....... https://coder.com/docs/code-server/requirements
# release image ...... https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# One service, and it is a development machine: VS Code's open-source core with
# a browser front end, the terminal it opens, and whatever gets installed from
# inside it. The image runs as uid 1000, the `coder` user baked into it, and
# binds code-server to 0.0.0.0:8080, so the three host directories below are
# owned by 1000. No `user:` line: the entrypoint runs fixuid first.
#
# PASSWORD arrives from /srv/code-server/.env, mode 600. code-server reads it,
# then deletes it from its own environment before starting anything, so the
# integrated terminal never inherits it. Telemetry and the update check are off
# in config/code-server/config.yaml, written before the first start. No Docker
# socket is mounted, deliberately.
#
# Tag and digest read from Docker Hub on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
code-server:
image: codercom/code-server:4.131.0@sha256:3623e6362abdec6258472882b06fdeec9d6ce2ad3fda316b3c5d7ed092b89add
container_name: code-server
restart: unless-stopped
# PASSWORD, and nothing else, generated on this server.
env_file: /srv/code-server/.env
volumes:
# config.yaml lives at config/code-server/config.yaml on the host.
- /srv/code-server/config:/home/coder/.config
# Extensions, editor settings and the machine id.
- /srv/code-server/local:/home/coder/.local
# The working tree. Empty until the user puts code in it.
- /srv/code-server/project:/home/coder/project
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8137.
- "127.0.0.1:8137:8080"
healthcheck:
# /healthz needs no authentication and never triggers a heartbeat.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd /srv/code-server && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one published port, no database: settings and
extensions live under `local` and the user's work under `project`.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-code-server
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# code-server · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coder.com/docs/code-server/guide,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream asks you
# to put a proxy in front of code-server that terminates TLS. This is it, and
# this hostname is the front door to a terminal on this server.
<DOMAIN> {
# The workbench is tens of megabytes of JavaScript on first load.
# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
# only, so a binary file opened in the editor passes through untouched.
encode zstd gzip
# code-server sends a Content-Security-Policy of its own and none of
# these. HSTS is on because every request to this host carries the
# session cookie for a shell.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8137 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. The editor rides
# WebSockets, which Caddy upgrades with no extra configuration, and Caddy
# forwards the original host, which is what code-server checks the
# Origin header against before accepting that upgrade.
reverse_proxy 127.0.0.1:8137
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-code-server, reload, and report what it objected to. Caddy requests
the certificate on the first request to the hostname and renews it itself, so nothing is
scheduled here.
## 6. Firewall
Two ports open, both Caddy's. Idempotent, so on a box Prompt Zero configured they change
nothing:
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, and
443/udp is HTTP/3. 8137 stays closed because compose binds it to 127.0.0.1 and Caddy is the only
thing reaching it. Assert: `ufw status verbose` prints `Status: active`, shows 80, 443/tcp
and 443/udp, and no rule mentioning 8137 or 8080. If a previous run left one, delete it with
`sudo ufw delete allow 8137`.
## 7. Start and verify
```bash
cd /srv/code-server
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/login | grep -o -E '<title>[^<]*</title>|Welcome to code-server|Password was set from .PASSWORD'
```
Assert all four, and print what you received for each. The loop ends printing `200`. The health
response is a small JSON object containing `"lastHeartbeat"`; its `status` reads `expired` on a
fresh instance, which is correct: the heartbeat starts only once a browser holds the editor
open. The bare URL prints `302`, an unauthenticated request sent to the login
page, and that is the security assert here. The last command prints three lines:
`<title>code-server login</title>`, `Welcome to code-server`, and `Password was set from
$PASSWORD`. The third proves step 3's value reached the container, because code-server prints
that sentence only when the password came from the environment.
If the bare URL prints `200` rather than `302`, stop and do not report success: authentication is
off. If the loop never reaches 200, stop, run `docker compose logs --tail 40 code-server`, and
name the likely earlier step: a container that exits at once is usually step 2 leaving a
directory owned by somebody other than uid 1000, and a `502` with a healthy container is step 5.
A running container is not success.
STOP: tell the user to read their password with `grep PASSWORD /srv/code-server/.env`, put it in
their password manager, open https://<DOMAIN>, sign in, and wait. Do not continue until they
confirm they see the editor. The first screen is one card reading `Welcome to code-server` above
`Please log in below.`, with one `PASSWORD` box, a `SUBMIT` button and no username.
Once they confirm:
```bash
curl -sS https://<DOMAIN>/healthz; echo
```
Assert: `status` now reads `alive`. It falls back to `expired` a minute after the last request,
so `alive` here means a browser is holding the editor open right now; if it still reads
`expired`, have the user reload the page and check again. Then tell them the folder on the left
is `/home/coder/project`, that it is empty, and a `git clone` in the editor's terminal fills it.
## 8. First backup and restore
One archive: the editor configuration, the extensions and settings, the working tree, compose.yml,
.env and the live Caddy site block.
```bash
cd /srv/code-server
docker compose stop
sudo tar -czf /srv/code-server/backups/code-server-$(date +%F).tar.gz -C /srv/code-server config local project compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/code-server/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds, and
the container is stopped on purpose because a file the editor is midway through writing is not a
backup.
A backup on the same disk is not a backup. Run this one from the user's machine, not the server:
```bash
mkdir -p ~/backups/code-server
scp vps:/srv/code-server/backups/*.tar.gz ~/backups/code-server/
```
To restore: `docker compose down`, remove `config`, `local` and `project` under /srv/code-server,
recreate the four directories as in step 2, untar the archive back into /srv/code-server, put the
Caddy block back if that is what was lost, then `docker compose up -d`. Say the last part plainly:
only those three folders are in the archive, so a toolchain installed inside it is not.
## 9. Updating later
New versions are listed at https://github.com/coder/code-server/releases. The Docker Hub tag drops
the leading `v`, so release `v4.132.0` is tag `4.132.0`. Take the backup first, then edit the
image line in /srv/code-server/compose.yml to the new tag and digest:
```bash
cd /srv/code-server
docker compose pull
docker compose up -d
docker compose logs --tail 30 code-server
```
Extensions and settings survive because they live in `local`, not in the image. Re-run the
`/healthz` and `302` checks from step 7 before calling the update done, and reload the browser
tab: an old workbench talking to a new server looks like a failed update.
## 10. What will probably go wrong
The health endpoint told me the install was dead when it was fine. I ran the `/healthz` check in
step 7, read `"status":"expired"` with `"lastHeartbeat":0`, and spent several minutes in the
container logs looking for a crash that had not happened. That field reports whether a browser is
holding the editor open, and on a server nobody has signed into the honest answer is no. The
`200` is the assert; the word in the body is not, and it flips to `alive` seconds after the
first sign-in.
## 11. Out of scope
- Do not repoint the extension gallery at Microsoft's Visual Studio Marketplace. Their terms
restrict those offerings to Visual Studio products and services; this build uses Open VSX.
- Do not mount the Docker socket into this container. That turns the editor's terminal into root
on the host, a different install with a threat model this prompt does not cover.
- Do not set `proxy-domain` or add a wildcard DNS record for the port proxy. A dev server is
already reachable at https://<DOMAIN>/proxy/3000/.
- Do not `apt-get install` toolchains inside the running container. Anything added that way is
gone at the next `docker compose pull`.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing code-server 4.131.0 on a VPS where Prompt Zero is done: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps`
unless a step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already
points at the box.
Read this before step 1. code-server is VS Code with a browser front end, and VS Code comes with
an integrated terminal, so `<DOMAIN>` becomes a shell on this server behind one password. Every
step below treats that password as the whole security boundary, because it is. The editor also
opens an empty folder: nothing is copied here from your laptop, and there is nothing to browse
until you clone a repository into it.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `1024` MB available, at least `10` G free, `amd64` or `arm64`, and your
server's IP on the last line. Upstream's stated floor is 1 GB of RAM and 2 CPU cores; the 10 GB
covers the image, the extensions and whatever you build.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
and run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. Under 10 GB free is worth
taking seriously here rather than rounding down: the image alone is several hundred megabytes
compressed, and a dependency folder in a real project is often larger than the editor.
## 2. Layout
Four directories and one configuration file. The container runs as uid 1000, the `coder` user
baked into the image, so three of the four belong to that uid rather than to you.
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/code-server /srv/code-server/backups
sudo install -d -m 750 -o 1000 -g 1000 /srv/code-server/local /srv/code-server/project
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/code-server/config /srv/code-server/config/code-server
cat > /srv/code-server/config/code-server/config.yaml <<'EOF'
auth: password
disable-telemetry: true
disable-update-check: true
EOF
sudo chown -R 1000:1000 /srv/code-server/config
ls -la /srv/code-server
sudo cat /srv/code-server/config/code-server/config.yaml
```
You should see: `local`, `project` and `config` owned by `1000`, `backups` owned by you, and the
three config lines printed back.
If you do not: if `ls` shows your own username on `local` or `project`, the `chown` did not take,
and the container will fail to write its settings on first start. Re-run the second `install`
line. Upstream writes that config file itself on first start, with a random password inside it,
and refuses to overwrite one that already exists. Writing it first keeps that second
credential-shaped string off your disk and turns telemetry off before a request leaves the box.
There is deliberately no `password` line in it: step 3 supplies that from the environment, and
the environment wins over the file either way.
## 3. Secrets
One secret, generated on the server, and it stands between the internet and a terminal on this
machine.
```bash
umask 077
cat > /srv/code-server/.env <<EOF
PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/code-server/.env
umask 022
ls -l /srv/code-server/.env
```
You should see: mode `-rw-------` and your own username twice. Read the value once with
`grep PASSWORD /srv/code-server/.env` and put it in your password manager. Hex rather than
base64, because Docker Compose reads this same file for variable interpolation and a `$` inside
the value would be expanded. Sixty-four hex characters is 256 bits, which is not overkill on a
login that is also a shell.
Do not paste that file, the password, or any command output containing it into this chat window.
The agent path never sees the value; this path will hand it to a third party unless you keep it
out yourself.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if you
pasted the lines separately in different shells. Run `chmod 600 /srv/code-server/.env` and carry
on. If the file already existed from an earlier attempt, this block has now replaced the
password, and the container will accept only the new one after the next restart.
Two things worth knowing about how the value travels. code-server deletes the variable from its
own environment before it starts anything else, so the terminal the editor opens does not inherit
it. And the login route refuses more than 2 attempts a minute plus 12 an hour, so a wrong
password three times in a row makes the next attempt fail even when you finally get it right;
wait a minute and try again.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/code-server/compose.yml <<'EOF'
# code-server · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://coder.com/docs/code-server/install
# faq and config ..... https://coder.com/docs/code-server/FAQ
# requirements ....... https://coder.com/docs/code-server/requirements
# release image ...... https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# One service, and it is a development machine: VS Code's open-source core with
# a browser front end, the terminal it opens, and whatever gets installed from
# inside it. The image runs as uid 1000, the `coder` user baked into it, and
# binds code-server to 0.0.0.0:8080, so the three host directories below are
# owned by 1000. No `user:` line: the entrypoint runs fixuid first.
#
# PASSWORD arrives from /srv/code-server/.env, mode 600. code-server reads it,
# then deletes it from its own environment before starting anything, so the
# integrated terminal never inherits it. Telemetry and the update check are off
# in config/code-server/config.yaml, written before the first start. No Docker
# socket is mounted, deliberately.
#
# Tag and digest read from Docker Hub on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
code-server:
image: codercom/code-server:4.131.0@sha256:3623e6362abdec6258472882b06fdeec9d6ce2ad3fda316b3c5d7ed092b89add
container_name: code-server
restart: unless-stopped
# PASSWORD, and nothing else, generated on this server.
env_file: /srv/code-server/.env
volumes:
# config.yaml lives at config/code-server/config.yaml on the host.
- /srv/code-server/config:/home/coder/.config
# Extensions, editor settings and the machine id.
- /srv/code-server/local:/home/coder/.local
# The working tree. Empty until the user puts code in it.
- /srv/code-server/project:/home/coder/project
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8137.
- "127.0.0.1:8137:8080"
healthcheck:
# /healthz needs no authentication and never triggers a heartbeat.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd /srv/code-server && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/code-server/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/code-server/compose.yml` and paste again in one go. There is no database here. The
editor keeps settings and extensions under `local` and your work under `project`, and both are
ordinary files on this disk.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in the
block with your hostname before you paste. The first line takes a copy, because a syntax error
here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-code-server
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# code-server · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coder.com/docs/code-server/guide,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream asks you
# to put a proxy in front of code-server that terminates TLS. This is it, and
# this hostname is the front door to a terminal on this server.
<DOMAIN> {
# The workbench is tens of megabytes of JavaScript on first load.
# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
# only, so a binary file opened in the editor passes through untouched.
encode zstd gzip
# code-server sends a Content-Security-Policy of its own and none of
# these. HSTS is on because every request to this host carries the
# session cookie for a shell.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8137 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. The editor rides
# WebSockets, which Caddy upgrades with no extra configuration, and Caddy
# forwards the original host, which is what code-server checks the
# Origin header against before accepting that upgrade.
reverse_proxy 127.0.0.1:8137
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-code-server /etc/caddy/Caddyfile`, reload,
and paste again. The WebSocket question is the one people ask here and the answer is that there
is nothing to do: Caddy performs the upgrade by default, and it forwards the original host, which
is the header code-server compares the browser's Origin against before accepting the connection.
A proxy that rewrote the host would break the editor with a `Forbidden` on the websocket and a
blank workbench, which is why this block does not touch it.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8137` or `8080`.
If you do not: delete anything for `8137` with `sudo ufw delete allow 8137`. 8137 is bound to
127.0.0.1 by the compose file, so a firewall rule for it would be opening a terminal to the
internet without the certificate or the proxy in front of it. 80/tcp answers the ACME challenge
and redirects to HTTPS, 443/tcp is the only way in, and 443/udp is HTTP/3, which Caddy offers by
default. `Status: inactive` is a different problem: Prompt Zero left this firewall enabled, so
something has turned it off since, and `sudo ufw enable` puts it back before you go further.
## 7. Start and verify
The first `docker compose pull` moves several hundred megabytes, so give it a minute.
```bash
cd /srv/code-server
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS https://<DOMAIN>/healthz; echo
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
curl -sS https://<DOMAIN>/login | grep -o -E '<title>[^<]*</title>|Welcome to code-server|Password was set from .PASSWORD'
```
You should see, in order: the loop reaching `200`; a small JSON object containing
`"lastHeartbeat"`; then `302`; then three lines reading `<title>code-server login</title>`,
`Welcome to code-server`, and `Password was set from $PASSWORD`.
If you do not: two of those deserve explanation. The health object's `status` field reads
`expired` on a brand-new install, and that is correct rather than broken, because the field
reports whether a browser is currently holding the editor open. And the `302` is the security
check in this step: an unauthenticated request being redirected to the login page. If that line
prints `200`, authentication is not on and you should stop here rather than open the hostname in
a browser. The third of the three grep lines is the other one that matters: code-server prints
`Password was set from $PASSWORD` only when the password came from the environment, so seeing it
proves step 3's value reached the container. If the loop never reaches `200`, run
`docker compose logs --tail 40 code-server`; a container that exits immediately is almost always
step 2 leaving a directory owned by somebody other than uid 1000.
Now open https://<DOMAIN> in a browser and sign in with the password from step 3. The first
screen is one card reading `Welcome to code-server` above `Please log in below.`, with a single
`PASSWORD` box, a `SUBMIT` button and no username field.
```bash
curl -sS https://<DOMAIN>/healthz; echo
```
You should see: `status` now reading `alive`.
If you do not: the field falls back to `expired` a minute after the last request, so `expired`
here means the browser tab is no longer open, or was never really connected. Reload the editor
and run the command again. A running container is
not success, and neither is a login page: `alive` is a real session holding a real workbench. Once
you are in, the folder on the left is `/home/coder/project` and it is empty. A `git clone` in the
editor's own terminal is how it fills.
## 8. First backup and restore
One archive: the editor configuration, the extensions and settings, the working tree, compose.yml,
.env and the live Caddy site block.
```bash
cd /srv/code-server
docker compose stop
sudo tar -czf /srv/code-server/backups/code-server-$(date +%F).tar.gz -C /srv/code-server config local project compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh /srv/code-server/backups/
```
You should see: one file, a few megabytes on a fresh install. Downtime is about five seconds, and
the container is stopped on purpose because a file the editor is midway through writing is not a
backup.
If you do not: an archive of a few hundred bytes means the `tar` ran before the directories had
anything in them, which is harmless today and useless tomorrow. Once you have installed
extensions and cloned a repository, this archive grows quickly, and the folders that drive that
are `local` and any dependency directory inside `project`. Exclude `node_modules` and build
output from later runs and let the package manager rebuild them.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not the
server:
```bash
mkdir -p ~/backups/code-server
scp vps:/srv/code-server/backups/*.tar.gz ~/backups/code-server/
```
You should see: one file copied, and it listed by `ls -lh ~/backups/code-server/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty folder:
```bash
cd /srv/code-server
docker compose down
sudo rm -rf /srv/code-server/config /srv/code-server/local /srv/code-server/project
sudo tar -xzf /srv/code-server/backups/code-server-$(date +%F).tar.gz -C /srv/code-server config local project
sudo chown -R 1000:1000 /srv/code-server/config /srv/code-server/local /srv/code-server/project
docker compose up -d
sleep 20
curl -sS -o /dev/null -w '%{http_code}\n' https://<DOMAIN>/
```
You should see: `302`, which means the editor came back with its configuration and its password
intact.
If you do not: a `502` means the container did not start, and the usual cause is the `chown` line
being skipped, because `tar` restores the uids it recorded and a mismatch leaves the container
unable to write. Note what is not in that archive: only those three folders and the two files.
Anything you installed into the container itself with `apt-get` is not in there and never was,
which is the honest limit of this backup and the reason a real toolchain belongs in your
project's own configuration.
## 9. Updating later
New versions are listed at https://github.com/coder/code-server/releases. The Docker Hub tag
drops the leading `v`, so release `v4.132.0` is image tag `4.132.0`. Take the backup first, then
edit the `image:` line in /srv/code-server/compose.yml to the new tag and its digest.
```bash
cd /srv/code-server
docker compose pull
docker compose up -d
docker compose logs --tail 30 code-server
```
You should see: the server starting, and no repeating restart.
If you do not: put the old tag and digest back and run the same three commands. Your extensions
and settings survive an update because they live in `local` on this disk rather than in the
image. Re-run the `/healthz` and `302` checks from step 7 before you call the update done, and
reload the browser tab as well: an old workbench talking to a new server reconnects badly and
looks like a failed update when it is a stale tab.
## 10. What will probably go wrong
The health endpoint told me the install was dead when it was fine. I ran the `/healthz` check in
step 7, read `"status":"expired"` with `"lastHeartbeat":0`, and spent several minutes in the
container logs looking for a crash that had not happened. That field reports whether a browser is
holding the editor open, and on a server nobody has signed into the honest answer is no. The
`200` is the assert; the word in the body is not, and it flips to `alive` seconds after the first
sign-in.
## 11. Out of scope
- Do not repoint the extension gallery at Microsoft's Visual Studio Marketplace. Their terms
restrict those offerings to Visual Studio products and services; this build uses Open VSX.
- Do not mount the Docker socket into this container. That turns the editor's terminal into root
on the host, a different install with a threat model this prompt does not cover.
- Do not set `proxy-domain` or add a wildcard DNS record for the port proxy. A dev server is
already reachable at https://<DOMAIN>/proxy/3000/.
- Do not `apt-get install` toolchains inside the running container. Anything added that way is
gone at the next `docker compose pull`.316 lines · 14,997 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install code-server 4.131.0 under ~/selfhost/code-server, answering at http://localhost:8137.
## 1. Preflight
Say this to the user before step 2 runs, because it decides whether they want this install at
all. code-server answers on this computer and nowhere else, so the tablet or borrowed laptop they
might have opened this editor on cannot reach it. What they get is a pinned Linux toolchain in a
container beside their own files rather than on top of them.
Detect the OS and measure the machine:
```bash
uname -s
id -u
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash. On Linux the
distribution ID and codename print next, for step 2. code-server needs 1024 MB of RAM available
and 10 GB free on the home disk; upstream's floor is 1 GB of RAM and 2 CPU cores, and the 10 GB
is the image, the extensions and whatever gets built. On macOS and Windows that memory figure is
the host's, and Docker Desktop takes its share of it. If either is under its floor, print both
numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
Four directories and one configuration file. The container runs as uid 1000, which is why the
Linux branch hands three of them to that uid.
```bash
cd ~ && mkdir -p selfhost/code-server/config/code-server selfhost/code-server/local selfhost/code-server/project selfhost/code-server/backups
cat > ~/selfhost/code-server/config/code-server/config.yaml <<'EOF'
auth: password
disable-telemetry: true
disable-update-check: true
EOF
if [ "$(uname -s)" = "Linux" ]; then
sudo chown -R 1000:1000 ~/selfhost/code-server/config ~/selfhost/code-server/local ~/selfhost/code-server/project
fi
ls -la ~/selfhost/code-server
```
Assert: `ls -la` shows `config`, `local`, `project` and `backups`. On macOS and Windows nothing
was chowned: Docker Desktop maps ownership. On Linux those three now belong to uid 1000, and if
step 1 printed anything other than `1000` for `id -u`, say so: those files are edited through
the editor, not the file manager.
Upstream would otherwise write that file itself on first start, with a random password in it.
Writing it first keeps that credential-shaped string off the disk and turns telemetry off before
a request leaves this machine. There is no `password` line: step 4 supplies it.
## 4. Secrets
One secret: the password on the editor. Generate it here. Do not print it, do not put it in your
summary, and keep it out of every log line.
```bash
umask 077
cat > ~/selfhost/code-server/.env <<EOF
PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/code-server/.env
umask 022
ls -l ~/selfhost/code-server/.env
```
Assert: the file exists with mode `-rw-------`. Git Bash ships openssl, so this runs the same on
all three systems. Hex rather than base64, because Docker Compose reads this file for
interpolation and a `$` in the value would be expanded. On Windows those mode bits are advisory:
NTFS does not enforce them, and the real boundary is the user's account.
Tell them `grep PASSWORD ~/selfhost/code-server/.env` reads the value and that it belongs in
their password manager now. code-server deletes the variable from its own environment before
starting anything, so the terminal does not inherit it.
## 5. compose.yml
```bash
cat > ~/selfhost/code-server/compose.yml <<'EOF'
# code-server · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://coder.com/docs/code-server/install
# faq and config ..... https://coder.com/docs/code-server/FAQ
# requirements ....... https://coder.com/docs/code-server/requirements
# release image ...... https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# One service, and it is a development machine: the editor, the terminal it
# opens, and whatever gets installed from inside it. Paths are relative to
# ~/selfhost/code-server/, so one file works on macOS, Linux and Windows.
#
# The image runs as uid 1000, the `coder` user baked into it, and binds
# code-server to 0.0.0.0:8080. On Linux the three directories below are chowned
# to 1000 during the install; on macOS and Windows Docker Desktop handles that.
# No `user:` line: the entrypoint runs fixuid first. PASSWORD arrives from
# ./.env, mode 600, and code-server deletes it from its own environment before
# starting anything. Telemetry and the update check are off in the config file.
# No Docker socket is mounted.
#
# Tag and digest read from Docker Hub on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
code-server:
image: codercom/code-server:4.131.0@sha256:3623e6362abdec6258472882b06fdeec9d6ce2ad3fda316b3c5d7ed092b89add
container_name: code-server
restart: unless-stopped
# PASSWORD, and nothing else, generated on this computer.
env_file: ./.env
volumes:
# config.yaml lives at config/code-server/config.yaml on this computer.
- ./config:/home/coder/.config
# Extensions, editor settings and the machine id.
- ./local:/home/coder/.local
# The working tree. Empty until you put code in it.
- ./project:/home/coder/project
ports:
# Loopback only: no other device on the wifi can reach 8137.
- "127.0.0.1:8137:8080"
healthcheck:
# /healthz needs no authentication and never triggers a heartbeat.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
EOF
cd ~/selfhost/code-server && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. One service, one port, three binds.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context, so the editor's crypto still works.
- No firewall rule. Nothing is published beyond loopback.
8137 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not anyone on the
internet. For something carrying a terminal, that is the whole model. Confirm it:
```bash
grep -n '"127.0.0.1:' ~/selfhost/code-server/compose.yml
```
Assert: one line, `- "127.0.0.1:8137:8080"`.
## 7. Start and verify
```bash
cd ~/selfhost/code-server
docker compose pull
docker compose up -d
for i in $(seq 1 30); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8137/healthz); echo "$i $code"; [ "$code" = 200 ] && break; sleep 5; done
curl -sS http://localhost:8137/healthz; echo
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:8137/
curl -sS http://localhost:8137/login | grep -o -E '<title>[^<]*</title>|Welcome to code-server|Password was set from .PASSWORD'
```
Assert all four, and print what you received for each. The loop ends printing `200`. The health
response is a small JSON object containing `"lastHeartbeat"`; its `status` reads `expired` on a
fresh instance, which is correct: the heartbeat starts only once a browser holds the editor open.
The bare URL prints `302`, an unauthenticated request sent to the login page, and that is the
security assert. The last prints `<title>code-server login</title>`, `Welcome to code-server` and
`Password was set from $PASSWORD`, the third proving step 4's value reached the container.
If the bare URL prints `200` rather than `302`, stop and do not report success: authentication is
off. If the loop never reaches 200, stop, run `docker compose logs --tail 40 code-server` and
name the likely cause: a container that exits at once is usually step 3 leaving a directory it
cannot write. If `port is already allocated` came back, find what holds 8137 with
`lsof -nP -iTCP:8137 -sTCP:LISTEN` (`netstat -ano | findstr :8137` on Windows) and stop.
STOP: tell the user to read their password with `grep PASSWORD ~/selfhost/code-server/.env`, put
it in their password manager, open http://localhost:8137, sign in, and wait. Do not continue
until they confirm they see the editor. The first screen is one card reading
`Welcome to code-server` above `Please log in below.`, with one `PASSWORD` box, a `SUBMIT` button,
no username.
Once they confirm:
```bash
curl -sS http://localhost:8137/healthz; echo
```
Assert: `status` now reads `alive`. It falls back to `expired` a minute after the last request,
so `alive` means a browser is holding the editor open; if it reads `expired`, have the user
reload the page. Then tell them the folder on the left is `/home/coder/project`, empty until a
`git clone` in the editor's terminal fills it.
## 8. First backup and restore
One archive: the config, extensions and settings, the working tree, compose.yml and .env.
```bash
cd ~/selfhost/code-server
docker compose stop
tar -C ~/selfhost/code-server -czf ~/selfhost/code-server/backups/code-server-$(date +%F).tar.gz config local project compose.yml .env
docker compose start
ls -lh ~/selfhost/code-server/backups/
```
Assert: the archive exists and is non-empty. Print its size. Downtime is about five seconds; the
container stops because a file caught mid-write is not a backup.
That archive sits on the same disk as the data, which is not a backup, and on a laptop the disk
and the machine fail together. Ask the user for a destination that leaves this computer, a folder
their sync service watches or a USB stick, and copy it there with `cp`. In Git Bash a Windows
drive is written `/d/Backups`. Assert: the user confirms the filename is there. If they have
nowhere, say plainly this install has no backup yet.
To restore: `docker compose down`, remove `config`, `local` and `project` under
~/selfhost/code-server, recreate them as in step 3, untar the archive back in, then
`docker compose up -d`. Only those three folders are in it, so a toolchain installed inside the
container is not.
## 9. Updating later
New versions are listed at https://github.com/coder/code-server/releases. The Docker Hub tag
drops the leading `v`, so `v4.132.0` is tag `4.132.0`. Back up first, then edit the image line in
compose.yml to the new tag and digest:
```bash
cd ~/selfhost/code-server
docker compose pull
docker compose up -d
docker compose logs --tail 30 code-server
```
Extensions and settings survive because they live in `local`, not the image. Re-run step 7's
`/healthz` and `302` checks, then reload the browser tab.
## 10. What will probably go wrong
I started a long build in the editor's terminal, closed the lid, and came back to a page saying
the connection was lost and a build stopped partway. Nothing was corrupt. This container is not
on a server: it sleeps when this computer sleeps, and a terminal session inside it survives that
no better than one in a local shell. Reload the tab, and if the editor does not come back run
`cd ~/selfhost/code-server && docker compose up -d`.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8137 to 0.0.0.0 so a tablet on the wifi can reach it. That puts a terminal on
this machine on every network the user joins, behind one password and no TLS.
- Do not mount the Docker socket into this container. That turns the editor's terminal into root
on this computer, a threat model this prompt does not cover.
- Do not repoint the extension gallery at Microsoft's Visual Studio Marketplace. Their terms
restrict it to Visual Studio products; this build uses Open VSX.compose.local.ymlthe services, pinned · local layout47 lines
# code-server · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# docker install ..... https://coder.com/docs/code-server/install
# faq and config ..... https://coder.com/docs/code-server/FAQ
# requirements ....... https://coder.com/docs/code-server/requirements
# release image ...... https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# One service, and it is a development machine: the editor, the terminal it
# opens, and whatever gets installed from inside it. Paths are relative to
# ~/selfhost/code-server/, so one file works on macOS, Linux and Windows.
#
# The image runs as uid 1000, the `coder` user baked into it, and binds
# code-server to 0.0.0.0:8080. On Linux the three directories below are chowned
# to 1000 during the install; on macOS and Windows Docker Desktop handles that.
# No `user:` line: the entrypoint runs fixuid first. PASSWORD arrives from
# ./.env, mode 600, and code-server deletes it from its own environment before
# starting anything. Telemetry and the update check are off in the config file.
# No Docker socket is mounted.
#
# Tag and digest read from Docker Hub on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
code-server:
image: codercom/code-server:4.131.0@sha256:3623e6362abdec6258472882b06fdeec9d6ce2ad3fda316b3c5d7ed092b89add
container_name: code-server
restart: unless-stopped
# PASSWORD, and nothing else, generated on this computer.
env_file: ./.env
volumes:
# config.yaml lives at config/code-server/config.yaml on this computer.
- ./config:/home/coder/.config
# Extensions, editor settings and the machine id.
- ./local:/home/coder/.local
# The working tree. Empty until you put code in it.
- ./project:/home/coder/project
ports:
# Loopback only: no other device on the wifi can reach 8137.
- "127.0.0.1:8137:8080"
healthcheck:
# /healthz needs no authentication and never triggers a heartbeat.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30sagent-readable mirror: /self-host/codesandbox.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned47 lines
# code-server · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# docker install ..... https://coder.com/docs/code-server/install
# faq and config ..... https://coder.com/docs/code-server/FAQ
# requirements ....... https://coder.com/docs/code-server/requirements
# release image ...... https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# One service, and it is a development machine: VS Code's open-source core with
# a browser front end, the terminal it opens, and whatever gets installed from
# inside it. The image runs as uid 1000, the `coder` user baked into it, and
# binds code-server to 0.0.0.0:8080, so the three host directories below are
# owned by 1000. No `user:` line: the entrypoint runs fixuid first.
#
# PASSWORD arrives from /srv/code-server/.env, mode 600. code-server reads it,
# then deletes it from its own environment before starting anything, so the
# integrated terminal never inherits it. Telemetry and the update check are off
# in config/code-server/config.yaml, written before the first start. No Docker
# socket is mounted, deliberately.
#
# Tag and digest read from Docker Hub on 2026-08-06; amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
code-server:
image: codercom/code-server:4.131.0@sha256:3623e6362abdec6258472882b06fdeec9d6ce2ad3fda316b3c5d7ed092b89add
container_name: code-server
restart: unless-stopped
# PASSWORD, and nothing else, generated on this server.
env_file: /srv/code-server/.env
volumes:
# config.yaml lives at config/code-server/config.yaml on the host.
- /srv/code-server/config:/home/coder/.config
# Extensions, editor settings and the machine id.
- /srv/code-server/local:/home/coder/.local
# The working tree. Empty until the user puts code in it.
- /srv/code-server/project:/home/coder/project
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8137.
- "127.0.0.1:8137:8080"
healthcheck:
# /healthz needs no authentication and never triggers a heartbeat.
test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30sCaddyfilethe hostname and TLS36 lines
# code-server · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://coder.com/docs/code-server/guide,
# https://caddyserver.com/docs/caddyfile/directives/reverse_proxy and
# https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. Upstream asks you
# to put a proxy in front of code-server that terminates TLS. This is it, and
# this hostname is the front door to a terminal on this server.
<DOMAIN> {
# The workbench is tens of megabytes of JavaScript on first load.
# Caddy's default encode matcher covers text, JSON, JavaScript and SVG
# only, so a binary file opened in the editor passes through untouched.
encode zstd gzip
# code-server sends a Content-Security-Policy of its own and none of
# these. HSTS is on because every request to this host carries the
# session cookie for a shell.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "no-referrer"
-Server
}
# 8137 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall. The editor rides
# WebSockets, which Caddy upgrades with no extra configuration, and Caddy
# forwards the original host, which is what code-server checks the
# Origin header against before accepting that upgrade.
reverse_proxy 127.0.0.1:8137
}install.shthe same install, no agent168 lines
#!/usr/bin/env bash
# code-server · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=code.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://coder.com/docs/code-server/install
# https://coder.com/docs/code-server/FAQ
# https://coder.com/docs/code-server/requirements
# https://coder.com/docs/code-server/guide
# https://github.com/coder/code-server/blob/v4.131.0/ci/release-image/Dockerfile
#
# Read this before you run it. code-server is VS Code with a browser front end,
# and VS Code ships an integrated terminal, so DOMAIN_HOST becomes a shell on
# this server behind one password. That password is generated here, written to
# /srv/code-server/.env with mode 600, and never printed. Read it yourself with
# grep PASSWORD /srv/code-server/.env
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/code-server}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
PORT=8137
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. code.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 1024 ] || die "only ${avail_mb} MB of RAM available; upstream's floor is 1024 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# The image runs as uid 1000, the `coder` user baked into it, so three of the
# four directories belong to that uid rather than to the login user.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 -o 1000 -g 1000 "$APP_DIR/local" "$APP_DIR/project"
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR/config" "$APP_DIR/config/code-server"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# Upstream writes this file itself on first start, with a random password in it,
# and refuses to overwrite one that exists. Writing it first keeps that second
# credential-shaped string off the disk and turns telemetry off before a request
# leaves the box. No `password` line: step 3 supplies that from the environment.
if [ ! -f "$APP_DIR/config/code-server/config.yaml" ]; then
cat > "$APP_DIR/config/code-server/config.yaml" <<-'CONFIG'
auth: password
disable-telemetry: true
disable-update-check: true
CONFIG
fi
sudo chown -R 1000:1000 "$APP_DIR/config"
# --- 3. Generate the one secret, on the server -------------------------------
#
# Hex rather than base64: Docker Compose reads this same file for variable
# interpolation and a `$` inside the value would be expanded. Sixty-four hex
# characters is 256 bits, which is not overkill on a login that is also a shell.
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-code-server"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and 8137 is not one of them -------------------------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; ${PORT} stays closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/healthz"
for _ in $(seq 1 30); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/healthz" || true)"
[ "$code" = "200" ] && break
sleep 5
done
[ "${code:-}" = "200" ] || die "/healthz answered ${code:-nothing}. Check: docker compose logs --tail 40 code-server"
# The heartbeat reads "expired" until a browser holds the editor open, so the
# assert is on the shape of the response rather than on that word.
curl -sS "https://${DOMAIN_HOST}/healthz" | grep -q 'lastHeartbeat' \
|| die "/healthz answered 200 without a heartbeat field. Check: docker compose logs --tail 40 code-server"
# An unauthenticated request must be sent to the login page. This decides
# whether the install is safe to leave running.
root_code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/" || true)"
[ "$root_code" = "302" ] || die "an unauthenticated request to / returned ${root_code}, not 302. Authentication is not on. Stop."
# code-server prints this line only when the password came from the
# environment, so it proves the .env value reached the container.
login_page="$(curl -sS "https://${DOMAIN_HOST}/login" || true)"
printf '%s' "$login_page" | grep -q 'Welcome to code-server' \
|| die "the login page did not render. Check: docker compose logs --tail 40 code-server"
printf '%s' "$login_page" | grep -q 'Password was set from' \
|| die "the login page is not using the generated password. Check that ${APP_DIR}/.env exists and holds one line."
unset login_page
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose stop
sudo tar -czf "$APP_DIR/backups/code-server-${STAMP}.tar.gz" -C "$APP_DIR" config local project compose.yml .env -C /etc/caddy Caddyfile
docker compose start
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/code-server-${STAMP}.tar.gz" ] || die "the backup archive is empty"
cat <<-DONE
code-server is answering at https://${DOMAIN_HOST}
1. Your password is in $APP_DIR/.env, mode 600. Read it with
grep PASSWORD $APP_DIR/.env
and put it in your password manager. It was not printed here.
Sign in at https://${DOMAIN_HOST} with that value and no username.
2. This hostname is a shell on this server. The editor's terminal runs
inside the container as uid 1000, on the three folders under
$APP_DIR, and the Docker socket is deliberately not mounted.
3. The folder on the left is /home/coder/project and it is empty. Fill
it with a git clone from the editor's own terminal.
4. Extensions come from Open VSX, not Microsoft's marketplace. Live
Share and the Remote extensions are not available on either.
5. First backup written to $APP_DIR/backups. It is on the same disk as
the data, which is not a backup. Copy it somewhere else tonight.
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel CodeSandbox.
- This is a remote shell on your server behind one password. code-server ships VS Code's integrated terminal, so whoever holds that password can run commands as the container's coder user, on the three folders mounted from the host, with passwordless sudo inside the container. The install generates 256 bits of randomness for it, never prints it, and the login route refuses more than 2 attempts a minute plus 12 an hour. Treat that one string the way you would treat an SSH key.
- Extensions come from Open VSX, not Microsoft's marketplace. Upstream says plainly that this is not entirely equivalent to Microsoft's VS Code, and Microsoft's terms restrict Marketplace offerings to Visual Studio products and services. Most things you use are on Open VSX; Live Share and the Remote extensions are closed source and are on neither. Check the two or three extensions you actually depend on before you migrate a working setup.
- You bring the code. The editor opens an empty folder on a fresh server, and it fills when you clone into it. Nothing is synced from your laptop and there is no template gallery, so the first ten minutes are a git clone and installing whatever your project needs.
- No throwaway environments. CodeSandbox's product is a fresh machine per branch, per pull request, per shared link, discarded when you are done. This is one persistent box you administer: when you break the toolchain, you are the person who fixes it, and there is no reset button behind the URL.
- You own the backups, and they have a hole in them. The archive covers the editor config, your extensions and settings, and the working tree. Anything you apt-get into the running container is not in it and disappears at the next image pull, so a toolchain worth keeping belongs in your project's own configuration.
Where this came from
“Though code-server takes the open-source core of VS Code and allows you to run it in the browser, it is not entirely equivalent to Microsoft's VS Code.”
- The published image runs as uid 1000 under a user named coder, exposes 8080, and its entrypoint binds code-server to 0.0.0.0:8080 after running fixuid. source
- code-server reads its password from $PASSWORD or $HASHED_PASSWORD and then deletes both from its own environment, so the processes it starts, including the integrated terminal, do not inherit them. source
- The login route allows 2 attempts a minute plus 12 an hour before it refuses further tries, and successful logins do not count against that limit. source
- Extensions come from the Open VSX gallery because Microsoft's terms state that Marketplace offerings are intended for use only with Visual Studio products and services, and Live Share and the Remote extensions are unavailable as a result. source
- The /healthz endpoint needs no authentication and reports the heartbeat, which reads expired until a browser is actually holding the editor open. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host CodeSandbox?
Not CodeSandbox itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is code-server. VS Code in a browser tab, running on a machine you own, with the terminal and the toolchain that come with it. The install is one command: one container behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 10 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces CodeSandbox?
code-server. VS Code in a browser tab, running on a machine you own, with the terminal and the toolchain that come with it. The closest thing to the editor half of CodeSandbox that you can run yourself: the same VS Code workbench, the same keybindings, the same terminal, reachable from any browser on a machine you control. What it does not reproduce is the part CodeSandbox actually charges for, which is a fresh disposable VM per branch and a link that boots one for someone else. This is one persistent environment that you maintain, and the honest trade is that you swap instant provisioning for never being metered. code-server is MIT-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to CodeSandbox?
1024 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $5 a month. code-server itself is free and MIT-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: CodeSandbox Pro, $12/mo — $144 a year.
How hard is it really?
ONE COMMAND — under 10 minutes. The rule that produced that verdict: one container, no database, no outside integration, at most one secret. Nothing to negotiate with anyone else, nothing to back up separately, at most one secret to generate. This is the case where the compose file honestly is the whole install. The tier is derived from seven countable facts about the code-server install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run code-server on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same code-server on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: On your own computer this answers only at http://localhost:8137, so the tablet or borrowed laptop you would have opened the editor on cannot reach it, and what you keep is a pinned Linux toolchain in a container sitting beside your own files rather than on top of them. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-07. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.