feat(webui): add browser terminal interface via ttyd

Adds a webui service to docker-compose that wraps Claude Code in ttyd,
serving a browser-accessible terminal on port 7681. The webui reuses
Dockerfile.claude (ttyd added to apt deps) with a dedicated entrypoint
script that enforces WEBUI_PASSWORD before starting. Network isolation
is identical to the CLI container: claude-internal only, all egress via
the proxy allowlist. claude.sh gains web and web-stop commands.
This commit is contained in:
docker-claude 2026-04-14 22:25:38 +02:00
parent c01102b641
commit 9b8562b746
7 changed files with 209 additions and 92 deletions

View file

@ -7,3 +7,7 @@ ANTHROPIC_API_KEY=sk-ant-...
# Optional: mount a host directory as /workspace inside the Claude container. # Optional: mount a host directory as /workspace inside the Claude container.
# If unset, a named Docker volume is used (fully isolated from the host). # If unset, a named Docker volume is used (fully isolated from the host).
# WORKSPACE_DIR=/absolute/path/to/your/project # WORKSPACE_DIR=/absolute/path/to/your/project
# Web interface credentials (required for ./claude.sh web)
# WEBUI_USER=claude
# WEBUI_PASSWORD=changeme

View file

@ -8,24 +8,28 @@ This file provides context and guidance for working with this project.
## Architecture ## Architecture
Two containers managed by Docker Compose: Three containers managed by Docker Compose:
- **`claude`** — Claude Code CLI, non-root (UID 1000), isolated to an internal-only Docker network - **`claude`** — Claude Code CLI, non-root (UID 1000), isolated to an internal-only Docker network
- **`webui`** — Claude Code as a browser terminal (ttyd on port 7681), same image as `claude`, non-root (UID 1000), same network isolation, basic auth required
- **`proxy`** — Squid forward proxy, non-root (UID 13), bridges the internal network to the internet with an egress allowlist - **`proxy`** — Squid forward proxy, non-root (UID 13), bridges the internal network to the internet with an egress allowlist
Key Docker network property: `claude-internal` has `internal: true`, meaning Docker adds no default gateway. The `claude` container physically cannot reach the internet without going through the `proxy` container. Key Docker network property: `claude-internal` has `internal: true`, meaning Docker adds no default gateway. The `claude` and `webui` containers physically cannot reach the internet without going through the `proxy` container.
The `webui` service reuses `Dockerfile.claude`. Its entrypoint (`webui-entrypoint.sh`) starts `ttyd --credential user:pass claude` instead of `claude` directly.
## File Structure ## File Structure
``` ```
docker-claude/ docker-claude/
├── claude.sh # Control script: start / stop / run / update / logs / status / shell ├── claude.sh # Control script: start/stop/run/web/web-stop/update/logs/status/shell
├── docker-compose.yml # Service definitions and network topology ├── docker-compose.yml # Service definitions and network topology
├── Dockerfile.claude # Claude Code container (node:20-slim, UID 1000) ├── Dockerfile.claude # Claude Code + ttyd container (node:20-slim, UID 1000)
├── Dockerfile.proxy # Squid proxy sidecar (ubuntu:22.04, UID 13) ├── Dockerfile.proxy # Squid proxy sidecar (ubuntu:22.04, UID 13)
├── webui-entrypoint.sh # Entrypoint for webui service: starts ttyd wrapping claude
├── proxy/ ├── proxy/
│ └── squid.conf # Squid ACL config — egress allowlist lives here │ └── squid.conf # Squid ACL config — egress allowlist lives here
├── .env.example # Template for ANTHROPIC_API_KEY ├── .env.example # Template for ANTHROPIC_API_KEY, WEBUI_PASSWORD, etc.
├── .gitignore # Excludes .env and logs ├── .gitignore # Excludes .env and logs
├── .dockerignore # Keeps .env out of build context ├── .dockerignore # Keeps .env out of build context
└── README.md # User documentation └── README.md # User documentation
@ -35,8 +39,9 @@ docker-claude/
```bash ```bash
chmod +x claude.sh chmod +x claude.sh
cp .env.example .env # set ANTHROPIC_API_KEY cp .env.example .env # set ANTHROPIC_API_KEY (and WEBUI_PASSWORD for web mode)
./claude.sh start # build + start proxy + launch Claude interactively ./claude.sh start # build + start proxy + launch Claude interactively (CLI)
./claude.sh web # build + start proxy + webui (browser terminal on :7681)
./claude.sh update # rebuild images (no cache) after upstream updates ./claude.sh update # rebuild images (no cache) after upstream updates
``` ```

View file

@ -6,8 +6,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
curl \ curl \
ca-certificates \ ca-certificates \
bash \ bash \
ttyd \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Entrypoint used by the webui service (ttyd wrapping claude)
COPY --chmod=755 webui-entrypoint.sh /usr/local/bin/webui-entrypoint.sh
# Create non-root user # Create non-root user
RUN groupadd -g 1000 claude \ RUN groupadd -g 1000 claude \
&& useradd -u 1000 -g claude -m -s /bin/bash claude && useradd -u 1000 -g claude -m -s /bin/bash claude

114
README.md
View file

@ -5,34 +5,36 @@ Runs [Claude Code](https://claude.ai/code) inside an isolated Docker environment
## Architecture ## Architecture
``` ```
┌─────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────────────────
│ Host machine │ │ Host machine │
│ │ │ │
│ claude.sh (control script) │ │ claude.sh (control script) │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ ┌─────────────────────────────────────────────┐ │ │ ┌──────────────────────────────────────────────────┐ │
│ │ Docker: claude-secure │ │ │ │ Docker: claude-secure │ │
│ │ │ │ │ │ │ │
│ │ ┌─────────────┐ claude-internal │ │ │ │ ┌─────────────┐ │ │
│ │ │ claude │◄─────(internal only)───► │ │ │ │ │ claude │──┐ claude-internal │ │
│ │ │ (UID 1000) │ │ │ │ │ │ │ (UID 1000) │ │ (internal: true) │ │
│ │ └─────────────┘ ┌──────┴──────┐ │ │ │ │ └─────────────┘ ├──────────────► ┌──────────┐ │ │
│ │ │ proxy │ │ │ │ │ ┌─────────────┐ │ │ proxy │ │ │
│ │ │ (UID 13) │ │ │ │ │ │ webui │──┘ │ (UID 13) │ │ │
│ │ └──────┬──────┘ │ │ │ │ │ (UID 1000) │ └────┬─────┘ │ │
│ │ proxy-external │ │ │ │ │ port 7681 │ proxy-external │ │
│ └─────────────────────────────────────────────┘ │ │ │ └─────────────┘ │ │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ internet (allowlisted) │ │ internet (allowlisted) │
└─────────────────────────────────────────────────────┘ └──────────────────────────────────────────────────────────
``` ```
- **`claude` container** — Claude Code, runs as UID 1000, on `claude-internal` only (no internet route) - **`claude`** — Claude Code CLI, UID 1000, on `claude-internal` only
- **`proxy` container** — Squid forward proxy, runs as UID 13, bridges `claude-internal` ↔ internet, enforces egress allowlist - **`webui`** — Claude Code in a browser terminal (ttyd), UID 1000, on `claude-internal` only, port 7681
- **`claude-internal`** — Docker bridge with `internal: true`; Docker adds no default gateway, so containers on this network cannot reach the internet directly - **`proxy`** — Squid forward proxy, UID 13, bridges `claude-internal` ↔ internet with egress allowlist
- **`proxy-external`** — Standard bridge; the proxy sidecar uses this for controlled outbound access - **`claude-internal`** — `internal: true`; no default gateway, containers cannot reach the internet directly
- **`proxy-external`** — Standard bridge; proxy sidecar only
## Prerequisites ## Prerequisites
@ -45,9 +47,9 @@ Runs [Claude Code](https://claude.ai/code) inside an isolated Docker environment
# 1. Clone / copy this repo # 1. Clone / copy this repo
git clone <repo> docker-claude && cd docker-claude git clone <repo> docker-claude && cd docker-claude
# 2. Configure your API key # 2. Configure credentials
cp .env.example .env cp .env.example .env
$EDITOR .env # set ANTHROPIC_API_KEY $EDITOR .env # set ANTHROPIC_API_KEY (and WEBUI_PASSWORD if using web mode)
# 3. Make the control script executable # 3. Make the control script executable
chmod +x claude.sh chmod +x claude.sh
@ -55,62 +57,77 @@ chmod +x claude.sh
## Usage ## Usage
### CLI mode
```bash ```bash
# Build images, start proxy, launch Claude Code interactively # Build images, start proxy, launch Claude Code interactively
./claude.sh start ./claude.sh start
# Same as start but skips image rebuild (faster on subsequent runs) # Start proxy if needed, launch Claude Code (faster on subsequent runs)
./claude.sh run ./claude.sh run
# Stop and remove all containers (proxy + any running sessions) # Mount a host directory as the workspace
./claude.sh stop
# Rebuild images without cache (e.g. after Claude Code updates)
./claude.sh update
# Tail proxy access logs
./claude.sh logs
# Show container status
./claude.sh status
# Open a debug bash shell inside the Claude container
./claude.sh shell
```
### Working with host files
By default, Claude's workspace is a named Docker volume (`claude-secure-workspace`) — fully isolated from the host.
To mount a specific host directory:
```bash
WORKSPACE_DIR=$HOME/myproject ./claude.sh run WORKSPACE_DIR=$HOME/myproject ./claude.sh run
``` ```
The directory is mounted at `/workspace` inside the container. ### Web interface
Serves Claude Code as a browser terminal via [ttyd](https://github.com/tsl0922/ttyd), protected by HTTP basic auth.
```bash
# Add to .env first:
# WEBUI_PASSWORD=your-strong-password
# WEBUI_USER=claude # optional, defaults to "claude"
./claude.sh web
# → Web interface running at http://0.0.0.0:7681
# To reach it from outside the sandbox host:
sbx ports <sandbox-name> --publish 7681:7681/tcp
# Stop web interface (keeps proxy running)
./claude.sh web-stop
```
### Other commands
```bash
./claude.sh stop # Stop and remove all containers
./claude.sh update # Rebuild images without cache
./claude.sh logs # Tail proxy logs
./claude.sh logs webui # Tail web interface logs
./claude.sh status # Show container status
./claude.sh shell # Debug bash shell in the Claude container
```
### Workspace
| Mode | Default | Override |
|---|---|---|
| CLI (`run`/`start`) | Named Docker volume (isolated) | `WORKSPACE_DIR=/path ./claude.sh run` |
| Web (`web`) | Named Docker volume (`claude-web-workspace`) | Edit `docker-compose.yml` volumes |
## Egress allowlist ## Egress allowlist
Edit `proxy/squid.conf` and add domains to the `allowed_sites` ACL: Edit `proxy/squid.conf` and add domains to the `allowed_sites` ACL:
```squid ```
acl allowed_sites dstdomain api.anthropic.com acl allowed_sites dstdomain api.anthropic.com
acl allowed_sites dstdomain statsig.anthropic.com acl allowed_sites dstdomain statsig.anthropic.com
# acl allowed_sites dstdomain api.github.com # uncomment if needed # acl allowed_sites dstdomain api.github.com
# acl allowed_sites dstdomain registry.npmjs.org # acl allowed_sites dstdomain registry.npmjs.org
``` ```
Rebuild the proxy after changes: Rebuild after changes:
```bash ```bash
docker compose -p claude-secure build proxy ./claude.sh update
./claude.sh stop && ./claude.sh start ./claude.sh stop && ./claude.sh start
``` ```
## Security controls ## Security controls
| Control | Claude container | Proxy container | | Control | claude / webui | proxy |
|---|---|---| |---|---|---|
| Non-root user | UID 1000 (`claude`) | UID 13 (`proxy`) | | Non-root user | UID 1000 (`claude`) | UID 13 (`proxy`) |
| `no-new-privileges` | yes | yes | | `no-new-privileges` | yes | yes |
@ -118,3 +135,4 @@ docker compose -p claude-secure build proxy
| Direct internet access | no (`internal` network only) | allowlisted only | | Direct internet access | no (`internal` network only) | allowlisted only |
| Host filesystem | no mounts by default | none | | Host filesystem | no mounts by default | none |
| Docker socket | not mounted | not mounted | | Docker socket | not mounted | not mounted |
| Web auth | basic auth (ttyd `--credential`) | n/a |

View file

@ -118,13 +118,40 @@ cmd_shell() {
dc run --rm --entrypoint /bin/bash $(workspace_flag) claude dc run --rm --entrypoint /bin/bash $(workspace_flag) claude
} }
cmd_web() {
check_deps
load_env
if [[ -z "${WEBUI_PASSWORD:-}" ]]; then
error "WEBUI_PASSWORD is not set. Add it to .env before starting the web interface."
exit 1
fi
info "Building images..."
dc build
info "Starting proxy and web interface..."
dc up -d webui
local port=7681
info "Web interface is up → http://0.0.0.0:${port}"
info "Credentials: ${WEBUI_USER:-claude} / [WEBUI_PASSWORD]"
warn "To reach it from outside this host, publish the port:"
warn " sbx ports <sandbox-name> --publish ${port}:${port}/tcp"
}
cmd_web_stop() {
check_deps
info "Stopping web interface..."
dc stop webui
dc rm -f webui
}
cmd_help() { cmd_help() {
cat <<EOF cat <<EOF
Usage: $(basename "$0") <command> [args] Usage: $(basename "$0") <command> [args]
Commands: Commands:
start [args] Build images, start proxy, launch Claude Code start [args] Build images, start proxy, launch Claude Code (CLI)
run [args] Start proxy if needed, launch Claude Code run [args] Start proxy if needed, launch Claude Code (CLI)
web Build images, start proxy + web interface (browser terminal)
web-stop Stop the web interface (keeps proxy running)
stop Stop and remove all containers stop Stop and remove all containers
update Rebuild images without cache update Rebuild images without cache
logs [svc] Tail logs (default: proxy) logs [svc] Tail logs (default: proxy)
@ -132,15 +159,19 @@ Commands:
shell Open a bash shell in the Claude container (debug) shell Open a bash shell in the Claude container (debug)
help Show this message help Show this message
Environment variables: Environment variables (set in .env or shell):
ANTHROPIC_API_KEY Required. Set in .env or exported in your shell. ANTHROPIC_API_KEY Required for all modes.
WORKSPACE_DIR Optional. Absolute path to mount as /workspace. WORKSPACE_DIR Optional (CLI mode). Host path to mount as /workspace.
Defaults to a named Docker volume (fully isolated). Defaults to a named Docker volume (fully isolated).
WEBUI_USER Web interface username (default: claude).
WEBUI_PASSWORD Required for web mode. Basic auth password.
Examples: Examples:
./claude.sh start ./claude.sh start
WORKSPACE_DIR=\$HOME/myproject ./claude.sh run WORKSPACE_DIR=\$HOME/myproject ./claude.sh run
./claude.sh web
./claude.sh logs proxy ./claude.sh logs proxy
./claude.sh logs webui
./claude.sh shell ./claude.sh shell
EOF EOF
} }
@ -150,6 +181,8 @@ case "${1:-help}" in
start) shift; cmd_start "$@" ;; start) shift; cmd_start "$@" ;;
stop) cmd_stop ;; stop) cmd_stop ;;
run) shift; cmd_run "$@" ;; run) shift; cmd_run "$@" ;;
web) cmd_web ;;
web-stop) cmd_web_stop ;;
update) cmd_update ;; update) cmd_update ;;
logs) shift; cmd_logs "${1:-}" ;; logs) shift; cmd_logs "${1:-}" ;;
status) cmd_status ;; status) cmd_status ;;

View file

@ -8,7 +8,7 @@ services:
context: . context: .
dockerfile: Dockerfile.proxy dockerfile: Dockerfile.proxy
networks: networks:
- claude-internal # reachable by the claude container - claude-internal # reachable by claude and webui containers
- proxy-external # has outbound internet access - proxy-external # has outbound internet access
restart: unless-stopped restart: unless-stopped
security_opt: security_opt:
@ -21,7 +21,7 @@ services:
- /var/spool/squid - /var/spool/squid
- /var/log/squid - /var/log/squid
# ─── Claude Code container ───────────────────────────────────────────────── # ─── Claude Code CLI container ─────────────────────────────────────────────
# No direct internet access. All egress routes through the proxy sidecar. # No direct internet access. All egress routes through the proxy sidecar.
# Run via "docker compose run --rm claude" (managed by claude.sh). # Run via "docker compose run --rm claude" (managed by claude.sh).
claude: claude:
@ -48,6 +48,41 @@ services:
# Workspace is injected by claude.sh via --volume flag at run time. # Workspace is injected by claude.sh via --volume flag at run time.
# Default: named Docker volume. Override: set WORKSPACE_DIR on the host. # Default: named Docker volume. Override: set WORKSPACE_DIR on the host.
# ─── Claude Code web interface ─────────────────────────────────────────────
# Serves Claude Code as a browser terminal via ttyd (port 7681).
# Protected by HTTP basic auth — set WEBUI_USER / WEBUI_PASSWORD in .env.
# Network isolation is identical to the CLI container.
webui:
build:
context: .
dockerfile: Dockerfile.claude
entrypoint: ["/usr/local/bin/webui-entrypoint.sh"]
depends_on:
proxy:
condition: service_healthy
networks:
- claude-internal # only — no route to the internet
environment:
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
- HTTP_PROXY=http://proxy:3128
- HTTPS_PROXY=http://proxy:3128
- ALL_PROXY=http://proxy:3128
- NO_PROXY=localhost,127.0.0.1
- WEBUI_USER=${WEBUI_USER:-claude}
- WEBUI_PASSWORD=${WEBUI_PASSWORD:-}
- WEBUI_PORT=7681
ports:
- "0.0.0.0:7681:7681"
volumes:
- claude-web-workspace:/workspace
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
stdin_open: true
tty: true
restart: unless-stopped
networks: networks:
# Internal-only: Docker adds no default gateway → no direct internet route # Internal-only: Docker adds no default gateway → no direct internet route
claude-internal: claude-internal:
@ -57,3 +92,7 @@ networks:
# External: standard bridge with internet access (proxy only) # External: standard bridge with internet access (proxy only)
proxy-external: proxy-external:
driver: bridge driver: bridge
volumes:
# Persistent workspace for the web interface
claude-web-workspace:

14
webui-entrypoint.sh Normal file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
# Entrypoint for the webui service.
# Wraps Claude Code in ttyd (terminal-over-WebSocket) with basic auth.
set -euo pipefail
: "${WEBUI_PASSWORD:?WEBUI_PASSWORD must be set in .env}"
WEBUI_USER="${WEBUI_USER:-claude}"
WEBUI_PORT="${WEBUI_PORT:-7681}"
exec ttyd \
--port "${WEBUI_PORT}" \
--writable \
--credential "${WEBUI_USER}:${WEBUI_PASSWORD}" \
claude