Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.git
.github
node_modules
server/node_modules
client/node_modules
site/node_modules
site
promo
docs
electron
dist
server/dist
client/dist
electron/dist
release
data
data-demo
*.db
*.db-journal
*.log
.env
.env.local
.astro
.claude
.trae
.codebuddy
.kiro
.agents
.uploads
skills
16 changes: 16 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

# Server
PORT=3721
# Docker Compose binds to localhost by default. Put a reverse proxy with HTTPS in
# front of ChatCrystal before exposing it publicly.
# BIND_ADDRESS=127.0.0.1
# CHATCRYSTAL_HOST_PORT=3721
# DATA_DIR=./data

# Data source overrides
Expand Down Expand Up @@ -57,3 +61,15 @@ PORT=3721
# LLM_BASE_URL=https://openrouter.ai/api/v1
# LLM_API_KEY=...
# LLM_MODEL=anthropic/claude-3.5-haiku

# Docker cloud deployment
# CHATCRYSTAL_IMAGE_TAG=latest
# CHATCRYSTAL_CLOUD_MODE=true
# CHATCRYSTAL_API_TOKEN=
# CHATCRYSTAL_ALLOW_INSECURE_REMOTE_HTTP=false
#
# In Docker, localhost points inside the container.
# For Ollama on Docker Desktop, use:
# CHATCRYSTAL_DOCKER_LLM_BASE_URL=http://host.docker.internal:11434
# CHATCRYSTAL_DOCKER_EMBEDDING_BASE_URL=http://host.docker.internal:11434
# The default docker-compose.yml already maps host.docker.internal for Linux.
175 changes: 175 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
name: Docker

on:
pull_request:
paths:
- Dockerfile
- docker-compose.yml
- docker-compose.build.yml
- .dockerignore
- package.json
- package-lock.json
- tsconfig.base.json
- README.md
- LICENSE
- NOTICE
- scripts/**
- server/**
- client/**
- shared/**
- .github/workflows/docker.yml
push:
branches: [main]
tags:
- v*
paths:
- Dockerfile
- docker-compose.yml
- docker-compose.build.yml
- .dockerignore
- package.json
- package-lock.json
- tsconfig.base.json
- README.md
- LICENSE
- NOTICE
- scripts/**
- server/**
- client/**
- shared/**
- .github/workflows/docker.yml
workflow_dispatch:

permissions:
contents: read

env:
IMAGE_NAME: ghcr.io/zengliangyi/chatcrystal

jobs:
validate:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Validate Compose
run: docker compose config

- name: Validate Compose source build override
run: docker compose -f docker-compose.yml -f docker-compose.build.yml config

- name: Build image
run: docker build -t chatcrystal:test .

- name: Smoke test CLI in image
run: docker run --rm --entrypoint crystal chatcrystal:test --version

- name: Smoke test container
run: |
docker run -d --name chatcrystal-smoke \
-e CHATCRYSTAL_CLOUD_MODE=true \
-e CHATCRYSTAL_API_TOKEN=ci-smoke-token-123456 \
-e DATA_DIR=/data \
-p 127.0.0.1::3721 \
chatcrystal:test
mapped="$(docker port chatcrystal-smoke 3721/tcp)"
port="${mapped##*:}"
for _attempt in $(seq 1 30); do
if curl -fsS "http://127.0.0.1:${port}/api/health"; then
exit 0
fi
sleep 1
done
docker logs chatcrystal-smoke
exit 1

- name: Cleanup
if: always()
run: docker rm -f chatcrystal-smoke || true

publish:
if: github.event_name != 'pull_request'
needs: validate
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
concurrency:
group: docker-publish-${{ github.ref }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
fetch-depth: 0

- name: Verify publish ref
run: |
git fetch --no-tags origin main
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
remote_sha="$(git rev-parse origin/main)"
if [ "${GITHUB_SHA}" != "${remote_sha}" ]; then
echo "::error::Refusing to publish because ${GITHUB_SHA} is not the current origin/main (${remote_sha})."
exit 1
fi
elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
echo "::error::Refusing to publish tag ${GITHUB_REF_NAME} because ${GITHUB_SHA} is not reachable from origin/main."
exit 1
fi
else
echo "::error::Docker image publishing is limited to main and v* tags."
exit 1
fi

- name: Set up QEMU
uses: docker/setup-qemu-action@v3

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=sha,prefix=sha-
type=ref,event=tag
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}

- name: Publish image
id: publish
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

- name: Verify public pull
run: |
docker logout ghcr.io || true
if ! docker pull "${IMAGE_NAME}@${{ steps.publish.outputs.digest }}"; then
echo "::error::GHCR image is not publicly pullable. Open the package settings for ghcr.io/zengliangyi/chatcrystal, change visibility to Public, then rerun this workflow."
exit 1
fi
if [ "${GITHUB_REF}" = "refs/heads/main" ]; then
docker pull "${IMAGE_NAME}:latest"
fi
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## Unreleased

- Added Docker cloud deployment for a personal single-instance ChatCrystal server.
- Added GHCR publishing workflow and Compose defaults for pulling the published image.
- Added first-run setup mode and shared bearer token authentication for Web, API, CLI, and MCP.
- Added CLI cloud connection commands and remote import from local AI tool histories.
- Disabled server-side local scan import in cloud mode to avoid scanning container paths.

## [0.4.9] - 2026-04-29

### Experience Quality Gate
Expand Down
53 changes: 53 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
FROM node:22-alpine AS build

WORKDIR /app

COPY package.json package-lock.json tsconfig.base.json ./
COPY server/package.json server/package.json
COPY client/package.json client/package.json
COPY shared/package.json shared/package.json

RUN npm ci

COPY . .
RUN npm run build

FROM node:22-alpine AS runtime

WORKDIR /app
LABEL org.opencontainers.image.source="https://github.com/ZengLiangYi/ChatCrystal"
LABEL org.opencontainers.image.description="ChatCrystal cloud server"
LABEL org.opencontainers.image.licenses="Apache-2.0"

ENV NODE_ENV=production
ENV PORT=3721
ENV DATA_DIR=/data
ENV CHATCRYSTAL_CLOUD_MODE=true

COPY package.json package-lock.json ./
COPY server/package.json server/package.json
COPY shared/package.json shared/package.json

RUN npm ci --omit=dev --workspace server --workspace shared --include-workspace-root=false --ignore-scripts \
&& npm cache clean --force

COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/client/dist ./client/dist
COPY --from=build /app/shared/types ./shared/types
COPY --from=build /app/README.md ./README.md
COPY --from=build /app/LICENSE ./LICENSE
COPY --from=build /app/NOTICE ./NOTICE

RUN chmod +x /app/server/dist/server/src/cli/index.js \
&& ln -s /app/server/dist/server/src/cli/index.js /usr/local/bin/crystal \
&& mkdir -p /data \
&& chown -R node:node /data

USER node
EXPOSE 3721
VOLUME ["/data"]

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:3721/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

CMD ["npm", "start", "-w", "server"]
Loading
Loading