code-server runs VS Code on a server for use in a browser, and its terminal runs commands on that server. This guide helps your AI assistant configure it securely, including SSH forwarding first, a tailnet or MFA-protected tunnel next, and otherwise a loopback bind behind a TLS proxy adding MFA.
If an AI assistant is helping you secure a deployment, give it the prompt below:
Read the secureconfig guide at
https://raw.githubusercontent.com/jposluns/secureconfig/main/code-server.md
in full, including the section for AI assistants at the end. Then, for my
deployment: state which of its recommendations apply and the exact changes
to make; compare the versions the guide was written against with the
releases I am running and with the vendor's current releases, and flag any
guidance a newer release may have changed; and run or adapt the guide's
Verify steps where you can. List separately every item you could not
verify instead of reporting it as passed.
Written against code-server v4.137.0. Metadata reviewed 2026-09-27; documentation checked September 2026. The guide records 18 claims, all reasoned from cited sources.
A newer release of any of this software can make version-specific details here outdated. To prioritize an update, open a GitHub issue, send a pull request, add a request under requests/, or reach the maintainer as CONTRIBUTING.md describes. The machine-readable version basis and claim list are in the section for AI assistants at the end of this guide.
code-server runs a terminal in the browser, so exposure equals remote code execution. Its own documentation is blunt: never expose it directly to the internet without authentication and encryption.
The code-server docs recommend SSH port forwarding first, which needs no additional setup:
ssh -N -o ExitOnForwardFailure=yes -L 127.0.0.1:8080:127.0.0.1:8080 user@host # then open http://127.0.0.1:8080 locallytailscale.md (serve, tailnet-only) and cloudflare.md (tunnel plus Access with MFA) are the equivalents when SSH is unavailable.
Edit the generated ~/.config/code-server/config.yaml in place; creating this file yourself before the first start removes the random password code-server would have generated, and password auth with neither password nor hashed-password fails to start. This is a partial configuration; keep the generated password entry (or set another supported credential):
bind-addr: 127.0.0.1:8080 # keep loopback behind a proxy or tunnel
auth: password # default; keep the generated password in this file
cert: false # TLS is off by default; terminate it at the proxy (below)Password attempts are rate-limited (2 per minute plus 12 per hour). Replace the generated password with your own long random value, and treat the config file as a secret (secrets.md). Know where the effective credential comes from: the PASSWORD and HASHED_PASSWORD environment variables override the file (the hashed form wins over plaintext), there is no --password command-line flag, and any flag passed to code-server overrides the file. The resulting step is to inspect the launcher (including --bind-addr and --config, and CODE_SERVER_CONFIG/XDG_CONFIG_HOME for the config path) rather than assuming the file is authoritative. The code-server process does not terminate TLS by default (cert: false); a bare --cert (or cert: true) generates a self-signed certificate at ~/.local/share/code-server/self-signed.crt, while --cert with --cert-key uses a certificate you supply. For public access, the docs' supported pattern is a reverse proxy with a real certificate: caddy.md or nginx.md with free-certificates.md, with MFA added at that layer (mfa.md) since the built-in login is a single factor. Never disable certificate validation with -k.
code-server also proxies other local services through /proxy/<port>/, /absproxy/<port>/ (which does not strip the path prefix), and per-port subdomains under --proxy-domain. These routes use code-server's own authentication, so auth: none exposes them too, and --skip-auth-preflight lets preflight requests through unauthenticated; if you do not need port forwarding, turn it off with --disable-proxy, and require your fronting auth and MFA on every enabled path, subdomain, and WebSocket route. Loopback binding only limits inbound reach: the terminal, extensions, and any service code-server proxies can still make outbound requests, so run the process with only the permissions and network it needs and keep it off cloud metadata and internal endpoints (egress-metadata.md).
REASONED: following block; listener inventory and direct-origin reachability, per the guide's cited code-server v4.137.0 deployment guide and HTTP controls; no isolated network namespace or external probe host is available, so neither check has run against a live listener. On the code-server host, ss -tlnp is a listener inventory in THIS namespace, not a firewall, publication, or authentication check: expect 8080 on loopback or a private address. Then run the following block from an untrusted external host. Any HTTP response there, even a 401, means the origin is reachable, which is a finding on its own, and no HTTP response is inconclusive, so the block cannot exit 0: confirm closure from the host and network firewall policy.
# REASONED: origin checks follow the cited code-server authentication and proxy
# documentation; no isolated network namespace is available for authorized live listeners.
# Shell syntax and guard checks do not demonstrate origin isolation.
# Direct-origin reachability: connect straight to the public IP and published port while keeping the URL
# hostname for TLS. From an untrusted external host, repeat for every public IPv4/IPv6 address and port.
# This block cannot exit 0: it exits 1 for any HTTP response and 3 for none.
(
set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_ORIGIN_URL' 'REPLACE_WITH_PUBLIC_IP' 'REPLACE_WITH_PUBLISHED_PORT'
[ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block; not probing'; exit 2; }
shift
[ "$#" -eq 3 ] || { echo 'exactly three values are required; not probing'; exit 2; }
case "$1|$2|$3" in
*REPLACE_WITH_*) echo 'substitute all values inside the quotes; not probing'; exit 2 ;;
esac
case "$1" in
http://*|https://*) ;;
*) echo 'an HTTP or HTTPS origin URL is required; not probing'; exit 2 ;;
esac
[ -n "$2" ] || { echo 'a public IP is required; not probing'; exit 2; }
case "$3" in
''|*[!0123456789]*) echo 'a numeric published port is required; not probing'; exit 2 ;;
esac
# bracket an IPv6 literal in REPLACE_WITH_PUBLIC_IP. Any HTTP response (incl 401/403) = a finding;
# http=000 is not a pass (a certificate, timeout, or local error is inconclusive). Never add -k.
set -- "$1" "$2" "$3" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \
--connect-to "::$2:$3" -o /dev/null \
-w 'origin http=%{http_code} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)"
set -- "${4%%$'\n'*}" "${4##*$'\n'}" # curl's write-out line, then its exit status
printf '%s\n' "$1"
case "$1" in
'origin http=000 '*) ;; # no HTTP code, so curl's exit status below decides
'origin http='[0123456789][0123456789][0123456789]' '*) set -- "$1" 0 ;; # an HTTP code is a response even if the transfer then failed
esac
if [ "$2" = 0 ]; then
echo "FINDING: the origin answered HTTP directly, so it is reachable"; set -- 1
else
echo "curl exit $2: no HTTP response, which is not a pass; inconclusive"; set -- 3
fi
echo "block exit $1; this block has no passing result, so confirm closure from the host and network firewall policy"
exit "$1"
)# REASONED: editor and proxy checks follow the cited code-server authentication and proxy
# documentation; no isolated network namespace is available for authorized live listeners.
# Shell syntax and guard checks do not demonstrate authentication.
# Auth discrimination against the SAME editor URL: a proxy 401 proves nothing, so pair an unauthenticated
# request with an authenticated one and confirm the editor in a fresh browser session too.
# Fixed: unauthenticated lands on login or challenge and authenticated reaches the editor. For MFA, confirm
# password-only access cannot open the editor and completing the second factor can. A transport, certificate,
# or unsuccessful positive control is inconclusive, not a pass.
(
trap - DEBUG RETURN ERR # assumes a clean shell (CONTRIBUTING rule 7): no inherited DEBUG trap, extdebug, function or alias
set +x +a +e
set -- PASTE_WHOLE_BLOCK 'REPLACE_WITH_HTTPS_EDITOR_URL'
[ "${1-}" = PASTE_WHOLE_BLOCK ] || { echo 'paste the whole block, including its set -- line; not probing'; exit 2; }
shift
[ "$#" -eq 1 ] || { echo 'the set -- line needs exactly 1 value; not probing'; exit 2; }
case "$1" in
''|*REPLACE_WITH_*) echo 'substitute the editor URL; not probing'; exit 2 ;;
https://*) ;;
*) echo 'an HTTPS URL is required; not probing'; exit 2 ;;
esac
# 2) Authenticated positive control: paste a cookie from a valid browser session; it enters via stdin
# (curl --header @-), never argv. The authenticated response must contain the editor.
# The hidden prompt keeps it out of shell history and set +x keeps it out of set -x tracing. Stdin
# closes the argv channel only: the account owner and root can still read it in the subshell and curl.
# The cookie is read before either request, so a refused cookie stops the block before any probe.
{ unset -n code_server_cookie && unset -v code_server_cookie; } 2>/dev/null ||
{ echo 'cannot initialize cookie input; not probing'; exit 2; }
{ unset -n IFS; } 2>/dev/null || { echo 'a readonly IFS is set in this shell; not probing'; exit 2; }
IFS= read -r -s -p 'Cookie value (name=value; name=value), then Enter: ' code_server_cookie < /dev/tty ||
{ printf '\n'; echo 'cookie input failed; not probing'; exit 3; }
printf '\n'
case "$code_server_cookie" in
''|*REPLACE_WITH_*|*[[:cntrl:]]*) echo 'a valid session cookie is required; not probing'; exit 2 ;;
esac
set -- "$1" 0 # the 2nd value is this block's status so far: 3 (inconclusive) outranks 0
# 1) Unauthenticated: follow redirects and inspect the FINAL body - login or challenge, never the editor.
set -- "$1" "$2" "$(if curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \
--location --max-redirs 5 --proto-redir '=https' \
-w '\nunauth http=%{http_code} url=%{url_effective} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)"
set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the response and its write-out line, then curl's exit status
printf '%s' "$3"
[ "$4" = 0 ] || { echo "curl exit $4: the unauthenticated request failed, which is inconclusive, not a pass"; set -- "$1" 3; }
# 2) The authenticated request, with the cookie read above.
set -- "$1" "$2" "$(if printf 'Cookie: %s\n' "$code_server_cookie" | \
curl -q -g -sS --noproxy '*' --connect-timeout 5 --max-time 20 \
--location --max-redirs 5 --proto-redir '=https' --header @- \
-w '\nauth http=%{http_code} url=%{url_effective} exit=%{exitcode} err=%{errormsg}\n' "$1"; then printf '\n0'; else printf '\n%s' "$?"; fi)"
set -- "$1" "$2" "${3%$'\n'*}" "${3##*$'\n'}" # the response and its write-out line, then curl's exit status
printf '%s' "$3"
set -- "$1" "$2" "$3" "$4" "${3##*auth http=}" # the final HTTP code and the rest of the write-out line
case "$4:${5%% *}" in
0:2??) ;;
0:*) echo "HTTP ${5%% *} with the cookie: the positive control did not succeed; inconclusive"; set -- "$1" 3 ;;
*) echo "curl exit $4: the authenticated request failed, which is inconclusive, not a pass"; set -- "$1" 3 ;;
esac
echo "block exit $2; both final bodies are not classified, so judge them yourself: login or challenge without the cookie, the editor with it"
exit "$2"
)REASONED: preceding block; paired editor access and MFA, with the listener inventory and direct-origin block above, per the guide's cited code-server v4.137.0 deployment guide and HTTP controls. No editor, proxy, IdP or external probe host is available; listeners are denied locally. bash -n and ShellCheck 0.11.0 exited 0 without diagnostics and config YAML parsed. Placeholder, missing-assignment, short, empty and embedded-placeholder inputs printed refusal messages without reaching curl stubs; substituted inputs reached the stubs; under a pseudo-terminal, empty, placeholder and control-character cookies exited 2 before the authenticated request, and a fixture cookie exited 0 and reached curl only on stdin as a Cookie: header. Those checks and runs used an earlier form: one fence whose exit status was the authenticated request's, with the cookie prompt after the unauthenticated request and a bare exit 2 when the prompt failed. The current direct-origin block exits 1 for any HTTP response and 3 for none. The current editor block reads the cookie before either request and exits 3 if that read fails, if a request fails or if the authenticated request ends on a non-2xx status; it exits 2 on a refused input and otherwise 0, leaving both final bodies for you to judge. On 2026-10-31 both current blocks passed bash -n and ran whole against stubs with no network; ShellCheck 0.11.0 runs on them in the repository's gate suite, not in that review. Live outcomes remain unobserved: exposed access opens the editor anonymously or bypasses MFA; fixed access isolates the origin, challenges anonymous users and requires the second factor while admitting the authenticated control. An unauthenticated editor in a private browser window means whoever finds the URL owns the host. Paste the cookie value alone at the hidden prompt: a multi-line paste leaves every line after the first for your shell to run.
- code-server v4.137.0 deployment guide (exposure, TLS and self-signed certificates, reverse proxy,
/proxy+/absproxy+--proxy-domainroutes,--skip-auth-preflight): https://raw.githubusercontent.com/coder/code-server/v4.137.0/docs/guide.md - code-server v4.137.0 FAQ (config.yaml keys map to flags,
cert: falsedefault,hashed-passwordprecedence, config path from--config/CODE_SERVER_CONFIG/XDG_CONFIG_HOME, flags override the file): https://raw.githubusercontent.com/coder/code-server/v4.137.0/docs/FAQ.md - code-server v4.137.0 CLI (credentials, defaults, config generation, proxy flags,
--passwordrejected on the CLI): https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/cli.ts - code-server v4.137.0 HTTP controls (authentication, proxy auth,
--disable-proxy): https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/http.ts - code-server v4.137.0 path proxy (
/proxy,/absproxyrouting): https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/routes/pathProxy.ts - code-server v4.137.0 domain proxy (
--proxy-domainsubdomains): https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/routes/domainProxy.ts
This generated section holds the recorded versions, claim statuses and source mappings for AI assistants; use the prompt at the top. This guide is guidance to verify against your own deployment, not a certification, an audit, or a guarantee of compliance with any standard.
Version basis
AI assistants must compare the versions listed below with current releases and treat this guide as guidance, re-verifying version-specific defaults when newer releases exist.
DEMONSTRATED refers to historical evidence in this guide; REASONED records source reasoning, not a live demonstration. Unknown means no version recorded.
Components (recorded basis):
- server: code-server (v4.137.0)
Claims (full source mapping in the JSON block below):
- ssh (REASONED; server): Vendor prefers SSH forwarding; bind the local 8080 forwarding listener to loopback and open the local browser URL.
- config (REASONED; server): Edit generated config.yaml and retain a supported credential; precreating it suppresses random-password generation and password auth without either credential fails startup.
- bind (REASONED; server): bind-addr 127.0.0.1:8080 keeps the editor private behind a proxy or tunnel.
- auth-default (REASONED; server): auth defaults password; replace the generated value with a long random secret and protect config.yaml.
- rate-limit (REASONED; server): Password attempts are limited to two per minute plus twelve per hour.
- credential-precedence (REASONED; server): PASSWORD/HASHED_PASSWORD override file credentials and hashed form wins; --password is not a supported CLI flag.
- config-precedence (REASONED; server): CLI flags override config; inspect --bind-addr, --config, CODE_SERVER_CONFIG and XDG_CONFIG_HOME for the effective launcher and file.
- tls-default (REASONED; server): cert defaults false; public access needs authenticated encryption, normally a reverse proxy with a trusted certificate.
- tls-native (REASONED; server): Bare --cert or cert:true generates ~/.local/share/code-server/self-signed.crt; --cert with --cert-key selects supplied material; retain certificate validation.
- mfa (REASONED; server): Built-in login is single-factor; fronting MFA must cover every enabled path, subdomain and WebSocket.
- path-proxy (REASONED; server): /proxy/<port>/ strips its prefix; /absproxy/<port>/ retains it; both use code-server authentication and auth:none exposes them.
- domain-proxy (REASONED; server): --proxy-domain exposes per-port subdomains using code-server authentication; apply fronting policy to each.
- preflight (REASONED; server): --skip-auth-preflight allows unauthenticated preflight requests.
- disable-proxy (REASONED; server): --disable-proxy turns off unneeded port forwarding.
- least-privilege (REASONED; server): Loopback limits inbound reach, not terminal/extension/proxied-service outbound access; minimize permissions and egress, including metadata/internal destinations.
- verify-origin (REASONED; server): ss inventories only this namespace; probe each public IPv4/IPv6 and port directly with intended TLS hostname; any HTTP response is reachability, and http=000 is inconclusive.
- verify-editor (REASONED; server): At the same editor URL, anonymous requests must reach login/challenge and valid-session requests the editor; a proxy 401 alone or failed positive control proves nothing.
- verify-mfa (REASONED; server): Password-only access must not open the editor while completing the second factor must; also confirm in a fresh browser.
{
"schema": 1,
"checked": "2026-09-27",
"documentation_checked": "2026-09",
"body_sha256": "e0d43f6557726ca6d38a8107a9fe375fc5cda077de8132d3223e5a431194ef5c",
"components": {
"server": {
"name": "code-server",
"basis": "v4.137.0",
"sources": {
"sc5e7573dedcf": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/docs/guide.md",
"sbe2b1714f8d5": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/cli.ts",
"s5c5105b71096": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/http.ts",
"s1ccbd73ebb52": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/routes/pathProxy.ts",
"s60678f2e26e9": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/src/node/routes/domainProxy.ts",
"s99eaa753a770": "https://raw.githubusercontent.com/coder/code-server/v4.137.0/docs/FAQ.md"
}
}
},
"claims": {
"ssh": {"text": "Vendor prefers SSH forwarding; bind the local 8080 forwarding listener to loopback and open the local browser URL.", "components": ["server"], "sources": ["server:sc5e7573dedcf"], "status": "REASONED"},
"config": {"text": "Edit generated config.yaml and retain a supported credential; precreating it suppresses random-password generation and password auth without either credential fails startup.", "components": ["server"], "sources": ["server:s99eaa753a770", "server:sbe2b1714f8d5"], "status": "REASONED"},
"bind": {"text": "bind-addr 127.0.0.1:8080 keeps the editor private behind a proxy or tunnel.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:sbe2b1714f8d5"], "status": "REASONED"},
"auth-default": {"text": "auth defaults password; replace the generated value with a long random secret and protect config.yaml.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:sbe2b1714f8d5"], "status": "REASONED"},
"rate-limit": {"text": "Password attempts are limited to two per minute plus twelve per hour.", "components": ["server"], "sources": ["server:sc5e7573dedcf"], "status": "REASONED"},
"credential-precedence": {"text": "PASSWORD/HASHED_PASSWORD override file credentials and hashed form wins; --password is not a supported CLI flag.", "components": ["server"], "sources": ["server:s99eaa753a770", "server:sbe2b1714f8d5"], "status": "REASONED"},
"config-precedence": {"text": "CLI flags override config; inspect --bind-addr, --config, CODE_SERVER_CONFIG and XDG_CONFIG_HOME for the effective launcher and file.", "components": ["server"], "sources": ["server:s99eaa753a770", "server:sbe2b1714f8d5"], "status": "REASONED"},
"tls-default": {"text": "cert defaults false; public access needs authenticated encryption, normally a reverse proxy with a trusted certificate.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s99eaa753a770", "server:sbe2b1714f8d5"], "status": "REASONED"},
"tls-native": {"text": "Bare --cert or cert:true generates ~/.local/share/code-server/self-signed.crt; --cert with --cert-key selects supplied material; retain certificate validation.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:sbe2b1714f8d5"], "status": "REASONED"},
"mfa": {"text": "Built-in login is single-factor; fronting MFA must cover every enabled path, subdomain and WebSocket.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s5c5105b71096"], "status": "REASONED"},
"path-proxy": {"text": "/proxy/<port>/ strips its prefix; /absproxy/<port>/ retains it; both use code-server authentication and auth:none exposes them.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s5c5105b71096", "server:s1ccbd73ebb52"], "status": "REASONED"},
"domain-proxy": {"text": "--proxy-domain exposes per-port subdomains using code-server authentication; apply fronting policy to each.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s5c5105b71096", "server:s60678f2e26e9"], "status": "REASONED"},
"preflight": {"text": "--skip-auth-preflight allows unauthenticated preflight requests.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:sbe2b1714f8d5"], "status": "REASONED"},
"disable-proxy": {"text": "--disable-proxy turns off unneeded port forwarding.", "components": ["server"], "sources": ["server:sbe2b1714f8d5", "server:s5c5105b71096"], "status": "REASONED"},
"least-privilege": {"text": "Loopback limits inbound reach, not terminal/extension/proxied-service outbound access; minimize permissions and egress, including metadata/internal destinations.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s5c5105b71096"], "status": "REASONED"},
"verify-origin": {"text": "ss inventories only this namespace; probe each public IPv4/IPv6 and port directly with intended TLS hostname; any HTTP response is reachability, and http=000 is inconclusive.", "components": ["server"], "sources": ["server:sc5e7573dedcf"], "status": "REASONED", "verify": [1]},
"verify-editor": {"text": "At the same editor URL, anonymous requests must reach login/challenge and valid-session requests the editor; a proxy 401 alone or failed positive control proves nothing.", "components": ["server"], "sources": ["server:sc5e7573dedcf", "server:s5c5105b71096"], "status": "REASONED", "verify": [2]},
"verify-mfa": {"text": "Password-only access must not open the editor while completing the second factor must; also confirm in a fresh browser.", "components": ["server"], "sources": ["server:sc5e7573dedcf"], "status": "REASONED", "verify": [2]}
}
}