diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..0415d714
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,47 @@
+# Git and GitHub
+.git
+.github
+.husky
+
+# Dependencies
+node_modules
+ui/node_modules
+
+# Build outputs (rebuilt in container)
+dist
+ui/dist
+ui/.vite
+
+# Test and coverage
+coverage
+ui/coverage
+tests
+**/__tests__
+
+# Environment files (SECURITY: prevent secret leakage)
+.env
+.env.*
+.env.local
+.env.production
+.env.development
+
+# Documentation (not needed for runtime)
+docs
+*.md
+!README.md
+!LICENSE
+
+# Logs and OS files
+*.log
+.DS_Store
+Thumbs.db
+
+# IDE and editor
+.vscode
+.idea
+*.swp
+*.swo
+
+# Misc
+*.tgz
+*.tar.gz
diff --git a/README.md b/README.md
index 92adff4a..a0382846 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,8 @@ ccs config
# Opens http://localhost:3000
```
+Want to run the dashboard in Docker? See `docker/README.md`.
+
### 3. Configure Your Accounts
The dashboard provides visual management for all account types:
diff --git a/docker/Dockerfile b/docker/Dockerfile
new file mode 100644
index 00000000..09b0be39
--- /dev/null
+++ b/docker/Dockerfile
@@ -0,0 +1,85 @@
+# syntax=docker/dockerfile:1
+
+# =============================================================================
+# Build stage: compile TypeScript and build UI
+# =============================================================================
+FROM node:20-bookworm-slim AS build
+
+SHELL ["/bin/bash", "-lc"]
+
+# Pin bun version for reproducible builds
+ARG BUN_VERSION=1.2.21
+ENV BUN_INSTALL=/usr/local/bun
+ENV PATH="$BUN_INSTALL/bin:$PATH"
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl ca-certificates unzip \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install specific bun version
+RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
+
+WORKDIR /app
+
+# Dependency install layer (avoid running install scripts inside the image build)
+COPY package.json bun.lock bunfig.toml ./
+COPY ui/package.json ui/bun.lock ./ui/
+
+RUN bun install --frozen-lockfile --ignore-scripts \
+ && (cd ui && bun install --frozen-lockfile --ignore-scripts)
+
+COPY . .
+
+RUN bun run build:all
+
+# Validate build artifacts exist
+RUN test -d dist && test -d lib && echo "[OK] Build artifacts validated"
+
+# =============================================================================
+# Runtime stage: minimal production image
+# =============================================================================
+FROM node:20-bookworm-slim AS runtime
+
+SHELL ["/bin/bash", "-lc"]
+
+# Pin bun version for reproducible builds
+ARG BUN_VERSION=1.2.21
+ENV BUN_INSTALL=/usr/local/bun
+ENV PATH="$BUN_INSTALL/bin:/home/node/.opencode/bin:$PATH"
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends curl ca-certificates unzip \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install specific bun version
+RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}"
+
+WORKDIR /app
+
+COPY package.json bun.lock ./
+RUN bun install --frozen-lockfile --production --ignore-scripts
+
+COPY docker/entrypoint.sh /usr/local/bin/ccs-entrypoint
+RUN chmod +x /usr/local/bin/ccs-entrypoint
+
+COPY --from=build /app/dist ./dist
+COPY --from=build /app/lib ./lib
+COPY --from=build /app/config ./config
+COPY --from=build /app/scripts ./scripts
+COPY --from=build /app/README.md ./README.md
+COPY --from=build /app/LICENSE ./LICENSE
+
+# Install AI CLI tools (using latest - pin versions in production if needed)
+# These are optional tools for docker exec usage
+RUN npm install -g @google/gemini-cli @vibe-kit/grok-cli @anthropic-ai/claude-code \
+ && npm install -g @kaitranntt/ccs --force \
+ && npm cache clean --force \
+ && su -s /bin/bash node -c 'curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path' \
+ && ln -sf /app/dist/ccs.js /usr/local/bin/ccs
+
+ENV CCS_PORT=3000
+EXPOSE 3000 8317
+
+ENTRYPOINT ["/usr/local/bin/ccs-entrypoint"]
+
+CMD ["bash", "-c", "node dist/ccs.js config --port ${CCS_PORT}"]
diff --git a/docker/README.md b/docker/README.md
new file mode 100644
index 00000000..d0204282
--- /dev/null
+++ b/docker/README.md
@@ -0,0 +1,205 @@
+
+
+# CCS Dashboard - Docker
+
+
+
+### Run the CCS Config Dashboard in Docker.
+Persistent config, restart on reboot.
+
+**[Back to README](../README.md)**
+
+
+
+
+
+## Quick Start (Docker Run)
+
+```bash
+docker build -f docker/Dockerfile -t ccs-dashboard:latest .
+docker run -d \
+ --name ccs-dashboard \
+ --restart unless-stopped \
+ -p 3000:3000 \
+ -p 8317:8317 \
+ -e CCS_PORT=3000 \
+ -v ccs_home:/home/node/.ccs \
+ ccs-dashboard:latest
+```
+
+Open `http://localhost:3000` (Dashboard).
+
+CCS also starts CLIProxy on `http://localhost:8317` (used by Dashboard features and OAuth providers).
+
+## Environment Variables
+
+Common CCS environment variables (from the docs):
+
+- Docs: [Environment variables](https://docs.ccs.kaitran.ca/getting-started/configuration#environment-variables)
+
+- `CCS_CONFIG`: override config file path
+- `CCS_UNIFIED_CONFIG=1`: force unified YAML config loader
+- `CCS_MIGRATE=1`: trigger config migration
+- `CCS_SKIP_MIGRATION=1`: skip migrations
+- `CCS_DEBUG=1`: enable verbose logs
+- `NO_COLOR=1`: disable ANSI colors
+- `CCS_SKIP_PREFLIGHT=1`: skip API key validation checks
+- `CCS_WEBSEARCH_SKIP=1`: skip WebSearch hook integration
+- Proxy: `CCS_PROXY_HOST`, `CCS_PROXY_PORT`, `CCS_PROXY_PROTOCOL`, `CCS_PROXY_AUTH_TOKEN`, `CCS_PROXY_TIMEOUT`, `CCS_PROXY_FALLBACK_ENABLED`, `CCS_ALLOW_SELF_SIGNED`
+
+Example (passing env vars to the running container):
+
+```bash
+docker run -d \
+ --name ccs-dashboard \
+ --restart unless-stopped \
+ -p 3000:3000 \
+ -p 8317:8317 \
+ -e CCS_PORT=3000 \
+ -e CCS_DEBUG=1 \
+ -e NO_COLOR=1 \
+ -e CCS_PROXY_HOST="proxy.example.com" \
+ -e CCS_PROXY_PORT=443 \
+ -e CCS_PROXY_PROTOCOL="https" \
+ -v ccs_home:/home/node/.ccs \
+ ccs-dashboard:latest
+```
+
+## Useful Commands
+
+```bash
+docker logs -f ccs-dashboard
+docker stop ccs-dashboard
+docker start ccs-dashboard
+docker rm -f ccs-dashboard
+```
+
+## Docker Compose (Optional)
+
+Using the included `docker/docker-compose.yml`:
+
+```bash
+docker-compose -f docker/docker-compose.yml up --build -d
+docker-compose -f docker/docker-compose.yml logs -f
+```
+
+Stop:
+
+```bash
+docker-compose -f docker/docker-compose.yml down
+```
+
+## Persistence
+
+- CCS stores data in `/home/node/.ccs` inside the container.
+- The examples use a named volume (`ccs_home`) to persist that data.
+- Compose also persists `/home/node/.claude`, `/home/node/.opencode`, and `/home/node/.grok-cli` via named volumes.
+
+## Resource Limits
+
+For production deployments, limit container resources:
+
+```bash
+docker run -d \
+ --name ccs-dashboard \
+ --restart unless-stopped \
+ --memory=1g \
+ --cpus=2 \
+ -p 3000:3000 \
+ -p 8317:8317 \
+ -v ccs_home:/home/node/.ccs \
+ ccs-dashboard:latest
+```
+
+Docker Compose includes default limits (1GB RAM, 2 CPUs). Adjust in `docker-compose.yml` under `deploy.resources`.
+
+## Graceful Shutdown
+
+CCS handles `SIGTERM` gracefully. When stopping the container:
+
+```bash
+docker stop ccs-dashboard # Sends SIGTERM, waits 10s, then SIGKILL
+docker stop -t 30 ccs-dashboard # Wait 30s for graceful shutdown
+```
+
+The `init: true` in docker-compose.yml ensures proper signal forwarding.
+
+## Troubleshooting
+
+### Permission Errors (EACCES)
+
+If you see permission errors on startup:
+
+```bash
+# Check volume permissions
+docker exec ccs-dashboard ls -la /home/node/.ccs
+
+# Fix by recreating volumes
+docker-compose down -v
+docker-compose up -d
+```
+
+### Port Already in Use
+
+```bash
+# Check what's using the port
+lsof -i :3000
+lsof -i :8317
+
+# Use different ports
+docker run -p 4000:3000 -p 9317:8317 ...
+
+# Or with compose
+CCS_DASHBOARD_PORT=4000 CCS_CLIPROXY_PORT=9317 docker-compose up -d
+```
+
+### Container Keeps Restarting
+
+```bash
+# Check logs for errors
+docker logs ccs-dashboard --tail 50
+
+# Check container health
+docker inspect ccs-dashboard --format='{{.State.Health.Status}}'
+```
+
+### Debug Mode
+
+Enable verbose logging:
+
+```bash
+docker run -e CCS_DEBUG=1 ...
+```
+
+## Examples: Claude + Gemini inside Docker
+
+Open a shell inside the running container:
+
+```bash
+docker exec -it ccs-dashboard bash
+```
+
+Claude (non-interactive / print mode):
+
+```bash
+docker exec -it ccs-dashboard claude -p "Hello from Docker"
+```
+
+Gemini (one-shot prompt):
+
+```bash
+docker exec -it ccs-dashboard gemini "Hello from Docker"
+```
+
+If you need to configure credentials, do it according to each CLI's docs:
+
+```bash
+docker exec -it ccs-dashboard claude --help
+docker exec -it ccs-dashboard gemini --help
+```
+
+## Security Notes
+
+- **Secrets**: For sensitive values like `CCS_PROXY_AUTH_TOKEN`, consider using Docker secrets or a `.env` file (not committed to git).
+- **Network**: The container exposes ports 3000 and 8317. In production, use a reverse proxy (nginx, traefik) with TLS.
+- **Updates**: Regularly rebuild the image to get security patches: `docker-compose build --pull`
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
new file mode 100644
index 00000000..1cd63701
--- /dev/null
+++ b/docker/docker-compose.yml
@@ -0,0 +1,53 @@
+# CCS Dashboard Docker Compose
+# See docker/README.md for documentation
+services:
+ ccs-dashboard:
+ image: ccs-dashboard:latest
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile
+ restart: unless-stopped
+ init: true
+ ports:
+ - "${CCS_DASHBOARD_PORT:-3000}:3000"
+ - "${CCS_CLIPROXY_PORT:-8317}:8317"
+ environment:
+ CCS_PORT: 3000
+ CCS_DEBUG: "${CCS_DEBUG:-}"
+ NO_COLOR: "${NO_COLOR:-}"
+ CCS_SKIP_PREFLIGHT: "${CCS_SKIP_PREFLIGHT:-}"
+ CCS_WEBSEARCH_SKIP: "${CCS_WEBSEARCH_SKIP:-}"
+ CCS_PROXY_HOST: "${CCS_PROXY_HOST:-}"
+ CCS_PROXY_PORT: "${CCS_PROXY_PORT:-}"
+ CCS_PROXY_PROTOCOL: "${CCS_PROXY_PROTOCOL:-}"
+ CCS_PROXY_AUTH_TOKEN: "${CCS_PROXY_AUTH_TOKEN:-}"
+ CCS_PROXY_TIMEOUT: "${CCS_PROXY_TIMEOUT:-}"
+ CCS_PROXY_FALLBACK_ENABLED: "${CCS_PROXY_FALLBACK_ENABLED:-}"
+ CCS_ALLOW_SELF_SIGNED: "${CCS_ALLOW_SELF_SIGNED:-}"
+ volumes:
+ - ccs_home:/home/node/.ccs
+ - claude_home:/home/node/.claude
+ - opencode_home:/home/node/.opencode
+ - grok_home:/home/node/.grok-cli
+ # Healthcheck uses internal ports (3000/8317) which are fixed
+ healthcheck:
+ test: ["CMD-SHELL", "curl -fsS --max-time 2 http://localhost:3000/ >/dev/null && curl -sS --max-time 2 http://127.0.0.1:8317/ >/dev/null"]
+ interval: 10s
+ timeout: 3s
+ retries: 12
+ start_period: 30s
+ # Resource limits (adjust based on workload)
+ deploy:
+ resources:
+ limits:
+ memory: 1G
+ cpus: '2'
+ reservations:
+ memory: 256M
+ cpus: '0.5'
+
+volumes:
+ ccs_home:
+ claude_home:
+ opencode_home:
+ grok_home:
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 00000000..1cca373d
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ccs_home_dir="${CCS_HOME_DIR:-/home/node/.ccs}"
+
+mkdir -p "$ccs_home_dir"
+
+# Fix volume permissions if running as root
+if [ "$(id -u)" = "0" ]; then
+ if ! chown -R node:node "$ccs_home_dir" 2>/dev/null; then
+ echo "[!] Warning: Could not change ownership of $ccs_home_dir (read-only volume?)" >&2
+ fi
+fi
+
+# Show usage if no command provided
+if [ "$#" -eq 0 ]; then
+ echo "[X] No command provided" >&2
+ echo "" >&2
+ echo "Usage: docker run ccs-dashboard " >&2
+ echo "" >&2
+ echo "Examples:" >&2
+ echo " docker run ccs-dashboard node dist/ccs.js config" >&2
+ echo " docker run ccs-dashboard ccs --help" >&2
+ echo "" >&2
+ exit 1
+fi
+
+# Drop privileges from root to node user
+if [ "$(id -u)" = "0" ]; then
+ cmd="$(printf '%q ' "$@")"
+ exec su -s /bin/bash node -c "exec ${cmd}"
+fi
+
+exec "$@"