Working remotely on lab projects meant a VPN or SSH keys on every device. code-server fixed that — full VS Code in a browser tab, locked behind Google SSO, running 24/7 on the Mac Mini. Here's how it works.
What's in the stack
- code-server by Coder — VS Code in the browser, packaged as a Docker image by LinuxServer.io
- Cloudflare Tunnel — outbound-only tunnel, no open ports on the router
- Authentik by Authentik Security — self-hosted SSO with Google OAuth2
- Nginx Proxy Manager by jc21 — reverse proxy that enforces forward auth on every request
Why not just use SSH?
SSH works, but requires a client and keys on every device, and you lose the full editor experience. code-server gives you extensions, an integrated terminal, and Claude Code in any modern browser. Once it's running and proxied, it works on iPad just as well as a laptop.
How the auth flow works
Every request to code.yourdomain.com goes through this chain:
Browser → Cloudflare Tunnel Edge → Nginx Proxy Manager → Authentik outpost check
↓ (if authenticated)
code-server
Nginx Proxy Manager uses auth_request to check every request against Authentik's embedded outpost. If you're not authenticated, you land on the Authentik login page — in this case, a Google OAuth2 prompt.
Deployment
1. The compose file
services:
code-server:
image: lscr.io/linuxserver/code-server:latest
container_name: code-server
environment:
- PUID=501
- PGID=20
- TZ=America/Chicago
- PASSWORD=${CODE_SERVER_PASSWORD}
- SUDO_PASSWORD=${CODE_SERVER_PASSWORD}
- DEFAULT_WORKSPACE=/config/workspace
volumes:
- /your/config:/config
- /your/projects:/config/workspace/Projects
ports:
- 8484:8443
restart: unless-stopped
Put real credentials in a .env file alongside the compose file:
CODE_SERVER_PASSWORD=your-password-here
Then chmod 600 .env so only your user can read it.
Important: always use docker compose up -d — not docker restart — when you change env vars. docker restart reuses the original environment from when the container was first created. docker compose up -d re-reads the compose file and .env.
2. Nginx Proxy Manager config
Create a proxy host for code.yourdomain.com:
- Forward Hostname: your server's local IP (not
localhost) - Forward Port:
8484 - WebSockets Support: ON — code-server won't work without this
Force SSL must be OFF. Cloudflare terminates TLS at the edge and sends plain HTTP to NPM. If you enable Force SSL in NPM, it will redirect to HTTPS and get redirected again — an infinite loop. NPM receives HTTP and your browser sees HTTPS because Cloudflare handles the certificate.
3. Authentik forward auth
After NPM creates the proxy host config file, patch it to add the Authentik blocks before the main location / block:
auth_request /outpost.goauthentik.io/auth/nginx;
error_page 401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie;
location /outpost.goauthentik.io {
proxy_pass http://your-server-ip:9010/outpost.goauthentik.io;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location @goauthentik_proxy_signin {
internal;
return 302 /outpost.goauthentik.io/start?rd=https://$http_host$request_uri;
}
The rd=https:// redirect URL is what brings you back to code-server after logging in. Without it, Authentik sends you to its own dashboard instead of where you were going.
Reload nginx after editing the conf: docker exec nginx-proxy-manager nginx -s reload
Three things I got wrong the first time
Credentials not updating after editing compose. Edited the compose file, ran docker restart, and the old password still worked. The fix is always docker compose up -d.
YAML special characters in passwords. A password containing & broke the compose file — YAML treats & as an anchor. Fix: move credentials to a .env file and reference them as ${VAR}.
Force SSL redirect loop. Enabled Force SSL in NPM because the site "should be HTTPS." Every request looped forever because NPM kept redirecting to HTTPS while Cloudflare kept sending plain HTTP. Remove the Force SSL block — Cloudflare handles TLS end to end.
Extensions on code-server
code-server uses the Open VSX Registry, not the Microsoft marketplace. Most extensions are available, but a few Microsoft-owned ones aren't. The ones I run:
for ext in \
anthropic.claude-code \
llvm-vs-code-extensions.lldb-dap \
mechatroner.rainbow-csv \
ms-azuretools.vscode-containers \
ms-python.debugpy \
ms-python.python \
swiftlang.swift-vscode \
tomoki1207.pdf; do
docker exec -u abc code-server /app/code-server/bin/code-server --install-extension "$ext"
done
Note: github.copilot-chat and ms-python.vscode-pylance are not on Open VSX and can't be installed.
Authenticating Claude Code in a container
The Claude Code extension needs Node.js to run its CLI agent. The LinuxServer.io code-server image doesn't include it, so you need to install it — and make it survive container recreations.
Create a startup script at config/custom-cont-init.d/install-claude.sh:
#!/bin/bash
if ! command -v node &> /dev/null; then
apt-get update -qq && apt-get install -y -qq nodejs npm > /dev/null 2>&1
fi
if ! command -v claude &> /dev/null; then
npm install -g @anthropic-ai/claude-code > /dev/null 2>&1
fi
LinuxServer.io images run anything in custom-cont-init.d/ at startup, so Node and Claude CLI persist across docker compose up -d cycles.
The billing gotcha: Claude Code supports two authentication methods — OAuth login (uses your Claude Pro/Max subscription) and API key (pay-as-you-go billing against your Anthropic API account). In a headless container, the OAuth browser flow doesn't work because the callback can't reach the container.
The workaround: if you're already authenticated on a desktop machine, your OAuth token lives in the system keychain. On macOS:
security find-generic-password -s "Claude Code-credentials" -w
That returns a JSON blob with an accessToken field — an sk-ant-oat prefixed token. Set that as your ANTHROPIC_API_KEY environment variable in the container's .env file. Despite the name, OAuth tokens route through your subscription, not pay-as-you-go.
ANTHROPIC_API_KEY=sk-ant-oat01-your-token-here
Important: sk-ant-api tokens bill against your API account. sk-ant-oat tokens bill against your subscription. If you generate a key from console.anthropic.com, that's an API key and you'll be charged per token. The OAuth token from the keychain uses your existing plan.
Giving Claude deeper access without opening new attack surface
Once Claude Code is running in the container, the natural next question is: can it actually manage infrastructure? Out of the box, the answer is mostly no. It can read and write files in the workspace, but it can't run Docker commands, push to GitHub, or use any custom skills you've built.
The naive fixes — mount the Docker socket, add SSH keys, install tools with root — each add real risk. Here's what I did instead.
GitHub CLI without sudo
The container doesn't have gh installed and you can't sudo apt install without a password prompt. The workaround is installing the binary directly to a directory you own:
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | sed 's/v//')
curl -sL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_arm64.tar.gz" \
-o /tmp/gh.tar.gz
mkdir -p /config/bin
tar -xz -f /tmp/gh.tar.gz -C /tmp
cp /tmp/gh_${GH_VERSION}_linux_arm64/bin/gh /config/bin/gh
echo 'export PATH="/config/bin:$PATH"' >> /config/.bashrc
Since /config is a persistent volume, the binary survives container recreations. Then gh auth login to authenticate — use a fine-grained personal access token scoped to only the repos Claude needs, rather than full OAuth. Smaller blast radius if the container is ever compromised.
Docker access via Portainer API
Mounting the Docker socket into a container is effectively handing out root on the host. Anyone who can talk to the socket can escape the container entirely. Don't do it.
If you're already running Portainer, it exposes a REST API that Claude can call with a token. Same outcome — create containers, inspect services, pull images — but the token is scoped, auditable, and revocable without touching the container.
In Portainer: Account Settings → Access Tokens → Add access token. Give it a name like claude-code-server.
Store the token as an environment variable in the container's .env file:
PORTAINER_URL=https://portainer.yourdomain.com
PORTAINER_TOKEN=your-token-here
Test from inside the container:
curl -s -H "X-API-Key: $PORTAINER_TOKEN" $PORTAINER_URL/api/endpoints
Claude can now call the Portainer API directly without any elevated host access.
Skills and agents
Custom Claude Code skills (slash commands) live in ~/.claude/commands/ on whatever machine Claude Code is running on. Inside the container, that resolves to /config/.claude/commands/ — and that directory doesn't exist by default.
The fix is a single volume mount in the compose file that points the container's commands directory at the one on the host:
volumes:
- /your/config:/config
- /your/projects:/config/workspace/Projects
- ~/.claude/commands:/config/.claude/commands:ro # add this
The :ro flag mounts it read-only — Claude can use the skills but can't modify them from inside the container. Restart the container and the skills show up in the next session.
Agent definitions stored as vault notes are already accessible since the vault is mounted as a workspace folder. No extra setup needed there.
End result
Authenticated VS Code at https://code.yourdomain.com — works on iPad, iPhone, any laptop. Sessions persist between visits, and extensions, settings, and workspace survive image updates since they live in the config volume.
The Obsidian vault is mounted as a workspace folder too, so notes edited in the browser sync to iCloud immediately.



Top comments (0)