* feat(docker): publish ccs:latest + ccs:full integrated images (P1)
* feat(docker): parameterize Dockerfile.integrated with ARG FLAVOR=minimal|full
Add FLAVOR build arg that gates the AI CLI install layer (claude-code,
gemini-cli, grok-cli, opencode) so one Dockerfile produces both the
minimal (< 350 MB) and full (< 600 MB) integrated images.
Use BuildKit cache mount for /root/.npm to speed up repeated builds.
Part (P1 — publish integrated images).
* feat(docker): emit startup deprecation warning in legacy ccs-dashboard entrypoint
Prepend a [WARN] line to stderr on every container start so operators
running ghcr.io/kaitranntt/ccs-dashboard:latest are notified to migrate
to ghcr.io/kaitranntt/ccs:latest. Sunset window: 2 releases. See .
* test(docker): add image-size.sh budget assertion + unit test suite
image-size.sh: asserts docker image inspect .Size against a byte budget.
Usage: image-size.sh <image:tag> <max-bytes>
Budgets: minimal=350 MB (367001600), full=600 MB (629145600)
image-size-logic.test.sh: 6 mock-docker unit tests covering pass/fail
boundaries, bad arg count, and non-integer input. All 6 pass locally.
Part (P1).
* ci(docker): extend docker-release.yml to publish ccs:latest and ccs:full
- Add publish-integrated job with matrix flavor: [minimal, full]
- minimal -> ghcr.io/kaitranntt/ccs:<ver> + :latest (when promoted)
- full -> ghcr.io/kaitranntt/ccs:full-<ver> + :full (when promoted)
- Multi-arch: linux/amd64 + linux/arm64 via buildx
- Scoped GHA cache per flavor to avoid cross-contamination
- Add promote_to_latest workflow_dispatch input (default false)
- rc.1 soak: first publish only pushes immutable version tag
- Mutable :latest/:full promoted only on release event OR explicit opt-in
- Add smoke-test job (post-publish) for each flavor
- Pulls the just-published version tag
- Asserts image size via tests/docker/image-size.sh
- Boots container, waits for healthcheck, probes :3000 and :8317
- Keep publish-dashboard job unchanged (legacy 2-release sunset)
- Updated labels to note deprecation status
All jobs run on self-hosted cliproxy runners. Part (P1).
* docs(docker): document new image tags, add ccs-dashboard deprecation notices
docker/README.md:
- Add "Choosing an image" table (ccs:latest / ccs:full / ccs-dashboard deprecated)
- Update Quick Start section to use ccs:latest as primary example
- Add legacy image note with sunset timeline
CHANGELOG.md:
- Add Unreleased > ### Deprecated entry for ccs-dashboard:latest
pointing to migration path and
README.md:
- Add one-line deprecation banner near top routing users to ccs:latest
Part (P1).
* refactor(docker): drop Bun from runtime stage; generate npm lockfile in build stage
- Build stage keeps Bun for fast installs; appends `npm install --package-lock-only`
to generate an ephemeral package-lock.json immediately after `bun run build:all`.
- Runtime stage removes BUN_VERSION ARG/ENV, the bun.sh curl install, and
`bun install --frozen-lockfile --production`. Replaces with:
COPY --from=build /app/package.json /app/package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
- --ignore-scripts rationale: postinstall (scripts/postinstall.js) writes ~/.ccs/
config — not needed in Docker context. bcrypt v6+ is pure-JS, no native compile.
- package-lock.json already in .gitignore (line 33); never committed.
- docker/Dockerfile.integrated unchanged — no Bun present there (alpine + npm).
- Targets: image size reduction >= 300 MB by eliminating ~130 MB Bun binary + installer.
* feat(docker): canonical compose.yaml + smoke-test (P2)
* feat(docker): add canonical compose.yaml for zero-install flow
* ci(docker): smoke-test ccs.kaitran.ca/docker-compose.yaml on release + nightly
* docs(docker): document ccs-net contract for sibling containers
Add "Connect your app to CLIProxy" section to docker/README.md with:
- Public contract table (network=ccs-net, service DNS=ccs, ports 8317/3000)
- Pattern A: same compose file with external network reference
- Pattern B: docker run --network ccs-net
- Troubleshooting subsection (DNS, missing network, conflict, Podman, MTU)
Add one-line link in README.md pointing to the new section.
Add CHANGELOG entry under Unreleased noting the contract as SemVer-major stable.
Add CONTRIBUTING.md note that changing services.ccs or networks.ccs-net requires major bump.
* docs(docker): P3 — hoist two-command quickstart, restructure docker/README, add parity CI
* docs: hoist Docker zero-install quickstart above npm install path
- Create docs/quickstart-snippet.md as canonical source for the
two-command flow (curl + docker compose up -d), wrapped in
<!-- quickstart-snippet-start/end --> markers
- Hoist the snippet into README.md immediately below the deprecation
banner, above all other install paths
- Rename old npm-only "## Quick Start" to "## Install on Host (npm)"
and move it below the Docker quickstart
* docs(docker): restructure README with zero-install first and migration section
- Reorder top-level sections: zero-install (canonical snippet with
markers), choosing an image, power-user ccs docker, prebuilt image,
connect your app to CLIProxy, migration, env vars, troubleshooting
- Add deprecation banner at the top pointing at the migration section
- Add ## Migration from ccs-dashboard:latest section with step-by-step
instructions covering compose down, data preservation, named volume
vs bind-mount path, and compose up with the new image
- Keep P1's Choosing an image table and P5's Connect Your App to
CLIProxy section intact, just repositioned
* test(docs): parity check for quickstart snippet across README files
Assert README.md and docker/README.md both contain the canonical
quickstart block verbatim, anchored by marker comments. Exits non-zero
and prints a diff on any drift.
* ci(docs): wire quickstart-parity test on push and PR
Runs tests/docs/quickstart-parity.sh on self-hosted runner whenever
docs/quickstart-snippet.md, README.md, docker/README.md, or the
test/workflow files themselves change. Fails fast on snippet drift.
* fix(docker): apply red-team findings — drop :full, rc.1 soak, healthcheck, signing
* docs(quickstart): fix raw URL for corporate-proxy fallback (H1)
* feat(docker)!: drop :full image variant — use sibling containers on ccs-net (Q3)
No AI CLIs (claude-code/gemini-cli/grok-cli/opencode) are bundled in the image.
Use sibling containers attached to ccs-net instead.
See docker/README.md#connect-your-app-to-cliproxy.
Also removes bash from apk deps (entrypoint uses #!/bin/sh — L2).
ci(docker): publish only immutable :<ver> tag pre-smoke; promote-mutable-tags
job adds :latest/:MAJOR/:MINOR aliases only after smoke tests pass (H3)
ci(docker): smoke-test-compose-url runs network-contract.sh against the
downloaded /tmp/ccs-compose.yaml instead of re-cloning the repo (H4)
ci(docker): sign published images with cosign keyless OIDC + attach
provenance/SBOM via build-push-action (M8)
test(docker): network-contract.sh now accepts compose-file and image-ref
positional args; replaces python3 healthcheck parser with jq (L4)
* chore(release): cut every main release as rc.N prerelease, manual promote flow (H2)
- .releaserc.cjs: main branch now uses prerelease 'rc' channel — every
semantic-release cut becomes vX.Y.Z-rc.N
- add promote-release.yml: workflow_dispatch flips rc → stable via
'gh release edit --prerelease=false'; triggers docker promote-mutable-tags
- add docs/release-process.md: full soak + promote procedure, rollback steps,
cosign verification command
- releaseNotesGenerator: add revert section, document chore hidden behaviour (L8)
* fix(docker): healthcheck probes both dashboard and cliproxy ports (M1)
compose.yaml healthcheck now checks :3000 and :8317 concurrently with
a 4.5s internal timeout, within Docker's 5s timeout budget.
Also:
- docs(docker): document npm lockfile ephemeral tradeoff above install
layer; note size-budget regression test as the practical safeguard (M3)
- docs(docker): drop :full row from Choosing an image table; add sibling
container note pointing to connect-your-app-to-cliproxy (Q3/docs)
- docs(docker): remove :full docker run block; fix release-tag sentence (Q3)
- docs(docker): fix raw URL in migration section (H1 parity)
- docs(docker): add Volume warning — 'down -v' deletes named volumes (L13)
- docs(docker): update What changes table — remove :full reference (Q3)
- docs(docker): add Image Signatures and SBOM section with cosign verify
and sbom download commands (M8/docs)
- changelog: add Unreleased entries for rc soak, cosign signing, :full
removal with migration guidance
* ci(docker): assert image-size budget per platform; add compose parity + breaking-change guard (M6/L9/L12)
- image-size.sh: add --platform flag; uses 'docker buildx imagetools
inspect' to sum compressed layer sizes from registry manifest for
linux/amd64 and linux/arm64 separately (M6)
- docker-release.yml smoke-test: runs size check for both platforms
- compose-parity.sh: diffs docker/compose.yaml vs
docker/docker-compose.integrated.yml for image name, ports 3000/8317,
volume mounts /root/.ccs and /var/log/ccs, ccs-net definition (L12)
- ci.yml: add compose-parity job wired to cliproxy runner (L12)
- breaking-change-guard.yml: fails PR if compose.yaml changes image name,
network name, or container_name without a feat!/fix! commit (L9)
* chore(ci): fix cosign shell substitution — use tr instead of bash @L expansion (L6/nit)
* test(docker): fix compose-parity port regex for variable-interpolated host ports
* fix: address upstream reviewer findings + failing CI checks (loop 1)
* fix(ci): breaking-change-guard skips missing base files (CI-1)
Guard each git-show call with git cat-file -e existence check before
attempting to read docker/compose.yaml from the base branch. When the
file doesn't exist on the base (new file in this PR), the script would
crash with "fatal: path exists on disk but not in origin/dev". New
files can't cause a contract regression, so we exit early with
breaking=0.
* style: prettier reformat unrelated drift (CI-2)
Two files had minor formatting drift from earlier umbrella PRs:
- src/cliproxy/quota/quota-manager.ts
- src/management/checks/image-analysis-check.ts
No logic changes — formatter-only pass to unblock CI format:check.
* fix(test): update trusted-author gate count to 4 in ci-workflow test (CI-3)
PR added a compose-parity job to ci.yml. That job runs on
self-hosted runners using PR-provided checkout, so it legitimately
requires the trusted-author guard (same as validate, build, test).
The test expected 3 occurrences; the correct count is now 4:
validate (matrix), build, test, compose-parity.
* fix(ci): smoke-test passes compose path + image ref to network-contract (REV-1)
network-contract.sh signature is: <compose-file> [image-ref]
The smoke-test job was calling it as:
bash tests/docker/network-contract.sh "${{ steps.image.outputs.ref }}"
which placed the image ref in the compose-file position ($1), causing
the script to try docker compose -f <image-ref> which fails.
Corrected to:
bash tests/docker/network-contract.sh docker/compose.yaml "${{ steps.image.outputs.ref }}"
Also removes publish-dashboard from smoke-test.needs (REV-2): when
publish-dashboard is SKIPPED on prerelease events, GitHub Actions
propagates the skip to downstream jobs, so smoke-test and
promote-mutable-tags were silently skipped on every rc.N publish.
smoke-test only verifies the integrated image; it has no dependency
on the legacy dashboard image job.
* docs(docker): annotate /root/.ccs path in compose volume (REV-3 clarification)
The reviewer raised a concern that the compose volume mounts /root/.ccs
but the entrypoint might default to /home/node/.ccs. This is a false
positive: the integrated image uses entrypoint-integrated.sh (not
entrypoint.sh), which runs under supervisord with user=root and
explicitly mkdir -p /root/.ccs. HOME is /root inside the container.
The volume mount at /root/.ccs is correct.
Added an inline comment documenting the reasoning so future reviewers
do not confuse entrypoint.sh (legacy dashboard image) with
entrypoint-integrated.sh (integrated image).
* fix(ci): gate docs-parity pull_request job to trusted authors
docs-parity.yml runs on a self-hosted runner and checks out PR code.
The self-hosted-runner-policy test requires any such workflow to include
the trusted-author guard. The workflow was missing the guard, causing
bun test:fast to fail with 1 failure.
Allow push events (no author check needed — push is to own branch)
and trusted-contributor PRs only.
* fix: address reviewer findings round 2 (loop 2)
* fix(docker): parameterize image with CCS_IMAGE env so smoke-test exercises new digest
docker/compose.yaml hardcoded image: ghcr.io/kaitranntt/ccs:latest, causing
network-contract.sh to always use the old published image regardless of what
IMAGE_OVERRIDE was passed. Switching to \${CCS_IMAGE:-ghcr.io/kaitranntt/ccs:latest}
lets CI pass CCS_IMAGE=<digest> so the smoke-test actually exercises the
just-built image. Default end-user behaviour is unchanged.
Resolves REV4 (reviewer loop 2, PR).
* fix(test): image-size.sh fails loudly on manifest inspection failure
The --platform branch previously exited 0 when imagetools inspect returned
empty or zero bytes, silently passing the budget check. A broken image
could reach production undetected if buildx had any inspection issue.
Changed to exit 1 with a clear diagnostic message explaining possible causes
(old buildx, manifest format mismatch, image not yet pushed). No --allow-inspect-failure
escape hatch is provided; CI must fix the root cause.
Resolves REV5 (reviewer loop 2, PR).
* test(docker): cover --platform branch in image-size-logic tests
Prior tests only exercised the local docker image inspect path. The
--platform branch (imagetools inspect) had zero coverage, meaning the
REV5 silent-pass regression would not have been caught by CI.
Added 5 mock-based test cases for the --platform branch:
- pass when platform-scoped compressed size < budget
- fail when platform-scoped compressed size > budget
- fail (exit 1) when imagetools inspect errors (REV5 guard)
- fail (exit 1) when reported size is "0" (REV5 guard)
- fail (exit 1) when size output is empty (REV5 guard)
Mocks follow the existing pattern (override docker in PATH with a temp
wrapper), extended to handle buildx imagetools inspect subcommand.
Resolves REV6 (reviewer loop 2, PR).
* fix(test): compose-parity image_name() handles \${VAR:-default} syntax
After parameterizing compose.yaml's image field with \${CCS_IMAGE:-...},
the image_name() sed pipeline cut at the first colon in the shell variable
syntax (:-) instead of the tag separator, returning '\${CCS_IMAGE' and
failing the kaitranntt/ccs grep.
Added a sed step to unwrap \${VAR:-default} by extracting just the default
value before stripping the tag. Existing plain image: name:tag references
are unaffected.
* fix: tighten parity check + catch service-key rename (loop 3)
* fix(test): tighten compose-parity image-name match to exact expected values (REV7)
Replace loose substring grep (grep -q "ccs") with exact equality checks
against declared EXPECTED_CANONICAL_IMAGE and EXPECTED_INTEGRATED_IMAGE
constants. The integrated compose builds locally as ccs-cliproxy:latest
(not ghcr.io/kaitranntt/ccs), so both expected names are explicitly
documented at the top of the assertion block.
Fixes: a drift in integrated compose to a wrong owner/registry could
slip through the old "grep -q ccs" check; now any name other than the
declared constant is a hard failure.
* feat(ci): breaking-change-guard catches services.ccs rename — public DNS contract (REV8)
Docker's service-name DNS uses the compose service KEY as the hostname.
Sibling containers on ccs-net reach CCS via http://ccs:8317; renaming
services.ccs: to anything else silently breaks that contract even when
image name, network name, and container_name are unchanged.
Add check 4 to the guard:
- Extract top-level service keys from both base and HEAD versions of
docker/compose.yaml using awk (no external YAML parser required).
- Fail if the "ccs" key is absent from HEAD.
- Fail if the sorted set of service keys differs from base.
Wraps inside the existing git cat-file guard so new-file PRs skip it.
* fix(ci): smoke-test failure check + relax service-key guard (REV9, REV10)
REV9 — docker-release.yml smoke-test: add HEALTHY flag to the boot-and-wait
loop. Previously a timeout (status stays 'starting'/'missing' for all 12
iterations) exited the loop silently, letting port probes be the only net.
Now if the loop exits without HEALTHY=1 the step fails immediately with
container logs and state dump. network-contract.sh already has the HEALTHY
pattern — no change needed there.
REV10 — breaking-change-guard.yml: drop the OLD_KEYS != NEW_KEYS comparison.
That check treated ANY change to the service-key set as breaking — including
adding a harmless sidecar. The DNS contract only requires services.ccs to
exist; other services are irrelevant to the 'ccs' hostname on ccs-net. Keep
only the "ccs key must exist" check; remove the unused OLD_KEYS extraction.
* fix(release)!: decouple npm @latest from Docker rc.1 soak (REV11)
Reverts the loop-1 prerelease channel that was publishing npm as
vX.Y.Z-rc.N to the `rc` dist-tag instead of `latest`.
`main` is now a stable semantic-release channel again: every merge
publishes vX.Y.Z immediately to npm @latest. The rc.1 soak window
that guards Docker mutable tags is moved entirely into the Docker
publish workflow:
- `.releaserc.cjs`: remove `prerelease: 'rc'` from productionConfig,
restore `branches: ['main']` (stable). Restore full successComment
and `released` label. Keep loop-1 releaseNotesGenerator additions
(revert section, breaking change comment).
- `docker-release.yml`: every `release: published` event publishes
only the immutable `:<ver>` Docker tag. `promote-mutable-tags` job
now gates exclusively on `workflow_dispatch` with
`promote_to_latest=true` — no longer triggered automatically by
non-prerelease release events.
- `promote-release.yml`: rewritten as a dispatch wrapper that validates
the stable tag exists and is not a prerelease, verifies the immutable
Docker image is in the registry, then dispatches docker-release.yml
with `promote_to_latest=true`. Removes the `gh release edit
--prerelease=false` approach that required the rc soak to be wired
through GitHub release state.
- `docs/release-process.md`: updated to reflect the decoupled model —
npm @latest is immediate; Docker :latest requires manual promote after
soak. Documents the `why` split between npm and Docker soak windows.
* fix(ci): reviewer loop 6 — REV12 compose image parsing, REV13 fork bypass, REV14 :full audit
* fix(ci): breaking-change-guard handles \${VAR:-default} compose image syntax (REV12)
The sed 's/:.*//' pattern truncated at the first colon in the
\${CCS_IMAGE:-ghcr.io/...} expression, yielding "\${CCS_IMAGE" as the
image name instead of the actual registry path. Any change to the default
image namespace was therefore undetectable.
Introduce extract_image_name() that first strips the \${VAR:-default}
wrapper with a sed -E expression, then strips only the trailing :tag
suffix using a pattern that preserves internal colons (e.g. registry:5000/
owner/repo). Applied to both OLD_RAW and NEW_RAW extraction paths.
* fix(ci): breaking-change-guard runs on ubuntu-latest to cover forked PRs (REV13)
The trusted-author gate (COLLABORATOR|MEMBER|OWNER) caused forked-PR
contributors to bypass the breaking-change check entirely. A forked
contributor could rename services.ccs or change the image namespace
without a feat!/fix! marker and the guard would never run.
This workflow is a documented exception to the self-hosted-first policy:
it performs ONLY pure YAML diff parsing (git show / awk / sed). No build,
install, or arbitrary PR-branch scripts are executed. The checkout uses
persist-credentials: false. There is no untrusted code execution, so
ubuntu-latest is safe and necessary for universal fork coverage.
Update self-hosted-runner-policy.test.ts to:
- Introduce GITHUB_HOSTED_RUNNER_EXCEPTIONS registry with required
justification comments for each entry
- Skip exception workflows in the "keeps active workflows on local runners"
and "gates pull-request workflows" assertions
- Add a new "documented exceptions use github-hosted runners" test that
verifies each exception entry actually uses a GitHub-hosted runner
(prevents stale entries accumulating without cleanup)
* docs(ci): clarify rc.1 soak and :full removal as intentional design
Reviewer kept flagging :full omission and stale :latest as bugs. Both are intentional per Q3 maintainer decision and the rc.1 soak design (loop-5). Strengthen inline comments to make this unmistakable to future readers.
* fix: address final-review criticals — healthcheck/digest/contract guards (loop 8)
* fix(docker): healthcheck fails on 4xx not only 5xx (H1)
Change threshold from >= 500 to >= 400 so a misrouted 404 on / is
treated as unhealthy. A container returning 4xx on the root path is
not serving traffic correctly and must not be reported healthy.
* chore(docker): pin eceasy/cli-proxy-api base image by digest (H2)
FROM eceasy/cli-proxy-api:latest@sha256:4fa722b... locks the base image
to a specific manifest-list digest for reproducible builds. The human-
readable :latest tag is preserved for readability. Refresh cadence:
quarterly or on security advisory from upstream. apk packages (nodejs,
npm) remain unpinned — noted as a known limitation until a CI workflow
for apk-digest pinning exists.
* fix(ci): breaking-change-guard catches network rename and hostname override (M1/M2)
Replace the old diff-based ccs-net check (which missed the case where
both old and new lack the name, producing identical empty strings) with
a positive assertion: the canonical compose MUST contain 'name: ccs-net'.
This catches renames, removals, and whitespace variations.
Also add a hostname: override guard (M2): setting services.ccs.hostname
changes the Docker DNS name on ccs-net even when the service key stays
"ccs", silently breaking http://ccs:8317 for sibling containers.
* fix(test): compose-parity image-name regex preserves registry:port (M5)
The old sed 's/:.*//' truncated at the first colon, incorrectly stripping
registry:port prefixes (e.g. registry.local:5000/owner/repo:tag became
registry.local). Replace with sed 's|:[^:/]*$||' which strips only the
trailing :tag suffix, preserving internal colons.
Move tr -d ' ' before the sed -E wrapper-strip so the '^' anchor matches
correctly after whitespace removal. Mirrors extract_image_name() in
breaking-change-guard.yml.
Add 3 image_name() regression tests to image-size-logic.test.sh covering
plain tag, env-var-with-default syntax, and registry:port/owner/repo:tag.
* ci(pr-agent): disable ticket analysis to avoid sub-issue crash
PR-Agent's fetch_sub_issues crashes when the PR body references PR numbers as shortcodes (etc) — GraphQL repository.issue(number) returns null for PR numbers and pr-agent dereferences without a guard (github_provider.py:1048). Disable ticket_analysis_review until upstream fixes the None guard. Other reviewer sections (security, score, tests, etc.) remain enabled.
* ci(pr-agent): re-enable ticket analysis after history+body rewrite
PR shortcodes removed from all commit messages and PR body, so fetch_sub_issues no longer hits the upstream null-deref bug. Ticket compliance review is re-enabled in full.
CCS Docker Deployment
Run CCS in Docker, locally or over SSH.
Persistent config, restart on reboot.
[Deprecation]
ghcr.io/kaitranntt/ccs-dashboard:latestis deprecated. Migrate toghcr.io/kaitranntt/ccs:latest. See Migration below.
Quick Start (Docker)
With Docker installed:
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yaml
docker compose up -d
Dashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Need a corporate-proxy alternative? Download directly:
https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml
Choosing an image
| Tag | Use | Approx. size | Status |
|---|---|---|---|
ghcr.io/kaitranntt/ccs:latest |
CCS + CLIProxy, no AI CLIs bundled | < 350 MB | Recommended |
ghcr.io/kaitranntt/ccs-dashboard:latest |
Legacy all-in-one image | > 600 MB | Deprecated — migrate to ccs:latest. Sunset after 2 releases. See #1251 |
ccs:latest also publishes pinned version tags (ccs:<major>.<minor>.<patch>, ccs:<major>.<minor>, ccs:<major>) for reproducible deployments.
Need claude-code, gemini-cli, grok-cli, or opencode? Run those tools in a sibling container attached to ccs-net — see Connect your app to CLIProxy. This keeps each tool independently versioned and prevents supply-chain bloat in the CLIProxy image.
Power-user: ccs docker
The CLI ships a first-class Docker command suite for the integrated CCS + CLIProxy stack:
ccs docker up
ccs docker status
ccs docker logs --follow
ccs docker config
ccs docker update
ccs docker down
Remote deployment stages the bundled Docker assets to ~/.ccs/docker on the target host:
ccs docker up --host my-server
ccs docker --host my-server status
ccs docker status --host my-server
ccs docker logs --host my-server --service ccs --follow
ccs docker config --host my-server
Use a single SSH target or SSH config alias for --host. If you need custom SSH flags such as a port override, configure them in ~/.ssh/config and reference the alias from ccs docker.
The ccs docker flow uses the integrated assets in this directory:
docker/Dockerfile.integrateddocker/docker-compose.integrated.ymldocker/supervisord.confdocker/entrypoint-integrated.sh
Post-Deployment: Enable Dashboard Auth (Required for Remote Access)
When accessing the dashboard from a different machine (not localhost), the API blocks requests with 403 Forbidden unless authentication is configured. Without auth, the dashboard appears empty (no providers, no version).
Set up auth inside the running container:
# Interactive setup (recommended)
docker exec -it ccs-cliproxy ccs config auth setup
# Or via environment variables in docker-compose
environment:
CCS_DASHBOARD_AUTH_ENABLED: "true"
CCS_DASHBOARD_USERNAME: "admin"
CCS_DASHBOARD_PASSWORD_HASH: "<bcrypt-hash>"
Running ccs config auth setup on the outer host shell updates that machine's own ~/.ccs, not the Docker volume mounted into ccs-cliproxy. For the integrated stack, configure auth inside the container or provide the auth env vars in Compose.
Generate a bcrypt hash:
docker exec ccs-cliproxy node -e "console.log(require('bcrypt').hashSync('your-password', 10))"
Note: Do not commit the password hash in
docker-compose.yml. Use Docker secrets or a.envfile (not tracked in git) for sensitive values likeCCS_DASHBOARD_PASSWORD_HASH.
After configuring auth, restart the dashboard:
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard
If accessing from localhost only (e.g., via SSH tunnel), auth is not required:
ssh -L 3000:localhost:3000 my-server
# Then open http://localhost:3000 in browser
Post-Deployment: Migrate Existing Auth Tokens
If you have existing CLIProxy OAuth tokens from a previous deployment, copy them into the Docker volume:
# Copy auth files into the running container
for f in /path/to/old/auth/*.json; do
docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/
done
# Restart CLIProxy to load new tokens
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
For remote deployments via ccs docker up --host:
# Copy tokens into the running container (no root/sudo needed)
scp /path/to/auth/*.json my-server:/tmp/ccs-auth/
ssh my-server 'for f in /tmp/ccs-auth/*.json; do docker cp "$f" ccs-cliproxy:/root/.ccs/cliproxy/auth/; done'
# Restart CLIProxy to load new tokens
ssh my-server "docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy"
# Clean up temp files
ssh my-server "rm -rf /tmp/ccs-auth"
Tip:
docker cpis preferred over writing directly to Docker volume mountpoints, which require root access.
Post-Deployment: Verification Checklist
After ccs docker up, verify the deployment:
# 1. Check container is healthy
ccs docker status --host my-server
# 2. Verify CLIProxy responds
curl -fsS http://<host>:8317/
# 3. Check health API (from inside container -- no auth needed)
docker exec ccs-cliproxy curl -fsS http://127.0.0.1:3000/api/health \
| python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{d[\"summary\"][\"passed\"]} passed, {d[\"summary\"][\"errors\"]} errors')"
# 4. Verify auth tokens loaded (check client count)
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
# 5. Test dashboard API (from remote -- requires auth)
curl -fsS -X POST http://<host>:3000/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"your-password"}'
Expected healthy output:
- Container status:
healthy - Both supervisor services:
RUNNING - CLIProxy health:
cliproxy-port: ok, CLIProxy running - Client count matches number of auth token files
Prebuilt Image Quick Start
Pull the recommended minimal image (CCS + CLIProxy, no AI CLIs):
docker run -d \
--name ccs \
--restart unless-stopped \
-p 3000:3000 \
-p 8317:8317 \
-e CCS_PORT=3000 \
-v ccs_home:/root/.ccs \
ghcr.io/kaitranntt/ccs:latest
Release-tag images are published as ghcr.io/kaitranntt/ccs:<version> for reproducible deployments.
Build Locally
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).
Connect Your App to CLIProxy
The CCS container joins a Docker network named ccs-net. This network name is a stable, public contract — it will not change without a SemVer-major release.
Network Contract
| Resource | Stable name | Notes |
|---|---|---|
| Network | ccs-net |
Attach any sibling container to this network |
| Service DNS | ccs |
Resolves to the CCS container from inside ccs-net |
| CLIProxy port | 8317 |
OAuth proxy — use as OPENAI_BASE_URL / CLIPROXY_URL |
| Dashboard port | 3000 |
Web UI |
| Env-friendly URL | http://ccs:8317 |
Drop into your app's env without port-mapping on the host |
Pattern A — Same Compose File
Declare ccs-net as external in your own compose file and add your service to it:
services:
my-app:
image: my-app:latest
environment:
CLIPROXY_URL: http://ccs:8317
networks:
- ccs-net
networks:
ccs-net:
external: true
Start CCS first so the network exists:
docker compose -f docker/compose.yaml up -d # or: ccs docker up
docker compose -f my-app/compose.yaml up -d
Pattern B — docker run
Attach a container at runtime without modifying any compose file:
docker run --rm \
--network ccs-net \
-e CLIPROXY_URL=http://ccs:8317 \
my-app:latest
Troubleshooting Network Issues
Service not resolvable from sibling container
Verify both containers are on ccs-net:
docker network inspect ccs-net
The output should list both ccs and your app container under Containers.
Network not found
The ccs-net network is created when the CCS stack starts. Run:
docker compose -f docker/compose.yaml up -d
# or: ccs docker up
Conflict with an existing ccs-net
If you already have a network named ccs-net from unrelated tooling, either rename yours or scope
the CCS project via COMPOSE_PROJECT_NAME:
COMPOSE_PROJECT_NAME=myproject docker compose -f docker/compose.yaml up -d
# Network becomes: myproject_ccs-net
Note: scoping changes the network name, so sibling compose files must use the same project name.
Podman / rootless containers
On rootless Podman, network names and DNS resolution may behave differently. Verify your Podman
version supports --network with named networks (podman network ls) and that aardvark-dns or
equivalent is installed for container-name resolution.
Low MTU on Hetzner and other cloud providers
Some cloud environments set a low MTU (e.g., 1450) on their overlay networks. If you see packet
fragmentation or stalled requests, add a custom MTU to the network in compose.yaml:
networks:
ccs-net:
name: ccs-net
driver_opts:
com.docker.network.driver.mtu: "1450"
Migration from ccs-dashboard:latest
ghcr.io/kaitranntt/ccs-dashboard:latest is deprecated and will stop publishing after 2 more
releases. Migrate to ghcr.io/kaitranntt/ccs:latest now.
Steps
-
Stop the old stack.
docker compose down # or if running via docker run: docker stop ccs-dashboard && docker rm ccs-dashboard -
Preserve your data.
Existing
~/.ccsdata on the host is not affected by the container change. If you were using a named volume (ccs_home), it persists automatically. If you were bind-mounting your host~/.ccs, continue doing so — just update the compose file path below. -
Get the new compose file.
curl -fsSL https://ccs.kaitran.ca/docker-compose.yaml -o docker-compose.yamlOr download manually from:
https://raw.githubusercontent.com/kaitranntt/ccs/main/docker/compose.yaml -
If you were bind-mounting
~/.ccs(instead of using a named volume), edit the downloadeddocker-compose.yamland replace theccs_homenamed volume with your bind mount:volumes: - ~/.ccs:/root/.ccsOtherwise the default named volume (
ccs_home) works out of the box. Let compose create it automatically, or create it manually first:docker volume create ccs_home -
Start the new stack.
docker compose up -dDashboard at http://localhost:3000 · CLIProxy at http://localhost:8317.
Warning: Use
docker compose down(without-v) to stop the stack.docker compose down -vdeletes named volumes includingccs_home, which permanently removes your CCS configuration and auth tokens. Always omit-vunless you intentionally want a clean wipe. -
Verify.
curl -fsS http://localhost:8317/
What changes
| Old | New |
|---|---|
ghcr.io/kaitranntt/ccs-dashboard:latest |
ghcr.io/kaitranntt/ccs:latest |
| > 600 MB image | < 350 MB image |
| Monolithic all-in-one | CCS + CLIProxy (AI CLIs via sibling containers on ccs-net) |
| No stable network contract | ccs-net network, ccs service DNS |
Environment Variables
Common CCS environment variables (from the docs):
-
Docs: 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):
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 \
ghcr.io/kaitranntt/ccs-dashboard:latest
Useful Commands
docker logs -f ccs-dashboard
docker stop ccs-dashboard
docker start ccs-dashboard
docker rm -f ccs-dashboard
Persistence
- CCS stores data in
/home/node/.ccsinside 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-clivia named volumes.
Resource Limits
For production deployments, limit container resources:
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 \
ghcr.io/kaitranntt/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:
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:
# 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
# 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
# Check logs for errors
docker logs ccs-dashboard --tail 50
# Check container health
docker inspect ccs-dashboard --format='{{.State.Health.Status}}'
Dashboard Shows Empty (No Providers, Wrong Version)
If the dashboard page loads but shows "0 providers", "Not running", or version "v5.0.0":
Cause: The dashboard API blocks non-localhost requests when auth is disabled (security feature). The page HTML loads from any host, but all API calls return 403.
Fix: Enable dashboard authentication:
docker exec -it ccs-cliproxy ccs config auth setup
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart ccs-dashboard
Then log in at the dashboard URL. See Post-Deployment: Enable Dashboard Auth above.
CLIProxy Shows 0 Clients After Token Migration
If CLIProxy logs show "0 clients" after copying auth tokens:
# CLIProxy needs a restart to detect new auth files
docker exec ccs-cliproxy supervisorctl -c /etc/supervisord.conf restart cliproxy
# Verify tokens loaded
docker exec ccs-cliproxy grep "client load complete" /var/log/ccs/cliproxy.log
ETXTBSY Error on First Boot
On first container start, you may see ETXTBSY: text file is busy in dashboard logs. This is a known race condition where the dashboard tries to update the CLIProxy binary while it's already running. The dashboard recovers automatically on the next attempt. No action needed.
Debug Mode
Enable verbose logging:
docker run -e CCS_DEBUG=1 ...
Examples: Claude + Gemini inside Docker
Open a shell inside the running container:
docker exec -it ccs-dashboard bash
Claude (non-interactive / print mode):
docker exec -it ccs-dashboard claude -p "Hello from Docker"
Gemini (one-shot prompt):
docker exec -it ccs-dashboard gemini "Hello from Docker"
If you need to configure credentials, do it according to each CLI's docs:
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.envfile (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
Image Signatures and SBOM
All ghcr.io/kaitranntt/ccs images are signed with cosign using keyless OIDC signing tied to the GitHub Actions workflow identity. A software bill of materials (SBOM) is attached to every image at publish time.
Verify a specific image digest:
cosign verify \
--certificate-identity-regexp "https://github.com/kaitranntt/ccs/.github/workflows/docker-release.yml" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
ghcr.io/kaitranntt/ccs:<version>
Inspect the SBOM:
cosign download sbom ghcr.io/kaitranntt/ccs:<version>
