diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80455a8 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +*.md +.env +.DS_Store +*.log diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 0000000..8d4c540 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,197 @@ +# ExcaliDash Docker Setup + +This Docker setup containerizes the ExcaliDash application with a multi-container architecture. + +## Architecture + +- **Frontend**: React/Vite app served by Nginx +- **Backend**: Express.js API with Socket.IO +- **Database**: SQLite (persisted in Docker volume) + +## Single Port Exposure + +The application exposes only **port 6767** externally, which serves the frontend. The frontend's Nginx acts as a reverse proxy to route: + +- `/api/*` requests to the backend container +- `/socket.io/*` WebSocket connections to the backend container + +All inter-container communication happens on an internal Docker network. + +## Quick Start + +### Option 1: Use Pre-built Images from Docker Hub (Recommended) + +Pull and run the latest multi-platform images: + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +### Option 2: Build Locally + +Build and run all services locally: + +```bash +docker compose up -d --build +``` + +### Access the application: + +Open your browser to `http://localhost:6767` + +## Publishing to Docker Hub + +### Build and Push Multi-Platform Images + +The `publish-docker.sh` script builds images for multiple platforms (amd64, arm64) and pushes them to Docker Hub: + +```bash +# Build and push with 'latest' tag +./publish-docker.sh + +# Build and push with a specific version +./publish-docker.sh v1.0.0 +``` + +**Prerequisites:** + +- Docker Buildx enabled +- Logged in to Docker Hub: `docker login` + +**Platforms supported:** + +- `linux/amd64` (Intel/AMD x86_64) +- `linux/arm64` (Apple Silicon, ARM servers) + +The script will: + +1. Create a buildx builder if needed +2. Build both frontend and backend images for all platforms +3. Push to `zimengxiong/excalidash-backend` and `zimengxiong/excalidash-frontend` +4. Tag with both the specified version and `latest` + +## Management Commands + +### View logs: + +```bash +# All services +docker compose logs -f + +# Specific service +docker compose logs -f backend +docker compose logs -f frontend +``` + +### Stop services: + +```bash +docker compose down +``` + +### Stop and remove volumes (clean slate): + +```bash +docker compose down -v +``` + +### Restart services: + +```bash +docker compose restart +``` + +### Check service status: + +```bash +docker compose ps +``` + +## Development + +For local development outside Docker, use the existing npm scripts: + +**Backend:** + +```bash +cd backend +npm install +npm run dev +``` + +**Frontend:** + +```bash +cd frontend +npm install +npm run dev +``` + +## Database + +The SQLite database is stored in a Docker volume named `backend-data` which persists data across container restarts. Database migrations run automatically when the backend container starts. + +## Environment Variables + +Default configuration works out of the box. To customize: + +Create `.env` files in `backend/` or `frontend/` directories: + +**Backend `.env`:** + +``` +PORT=8000 +NODE_ENV=production +``` + +**Frontend `.env`:** + +``` +VITE_API_URL=/api +``` + +## Troubleshooting + +### Check health status: + +```bash +docker-compose ps +``` + +Both services should show "healthy" status. + +### Reset database: + +```bash +docker-compose down -v +docker-compose up -d +``` + +### View detailed backend logs: + +```bash +docker-compose logs backend +``` + +### Rebuild specific service: + +```bash +docker-compose up -d --build backend +``` + +## Production Deployment + +For production deployment: + +1. Use proper environment variables +2. Configure proper CORS settings in the backend +3. Add HTTPS/TLS termination (e.g., via reverse proxy like Traefik or nginx) +4. Consider using PostgreSQL instead of SQLite for better concurrency +5. Set up proper backup strategy for the `backend-data` volume + +## Port Mapping + +- **6767** (external) → **80** (frontend nginx) → Routes to backend on internal network +- **8000** (internal only) - Backend API server + +Only port 6767 is accessible from outside the Docker network. diff --git a/PUBLISHING.md b/PUBLISHING.md new file mode 100644 index 0000000..9ad9b8a --- /dev/null +++ b/PUBLISHING.md @@ -0,0 +1,102 @@ +# Publishing Docker Images + +This document explains how to build and publish multi-platform Docker images for ExcaliDash. + +## Quick Start + +```bash +# Login to Docker Hub (if not already logged in) +docker login + +# Build and push images +./publish-docker.sh +``` + +## Usage + +```bash +./publish-docker.sh [VERSION] +``` + +**Arguments:** + +- `VERSION` (optional): The version tag for the images. Defaults to `latest`. + +**Examples:** + +```bash +# Build and push with 'latest' tag +./publish-docker.sh + +# Build and push with version tag +./publish-docker.sh v1.0.0 +./publish-docker.sh 2024.11.21 +``` + +## What It Does + +1. **Checks Docker Hub authentication** - Ensures you're logged in +2. **Sets up buildx builder** - Creates or uses existing multi-platform builder +3. **Builds backend image** - For linux/amd64 and linux/arm64 platforms +4. **Builds frontend image** - For linux/amd64 and linux/arm64 platforms +5. **Pushes to Docker Hub** - Uploads to `zimengxiong/excalidash-backend` and `zimengxiong/excalidash-frontend` + +## Supported Platforms + +- **linux/amd64** - Intel/AMD x86_64 processors (most servers, PCs) +- **linux/arm64** - ARM 64-bit processors (Apple Silicon, ARM servers, Raspberry Pi 4+) + +## Requirements + +- Docker with buildx support (Docker Desktop or Docker Engine 19.03+) +- Docker Hub account credentials +- Internet connection for pushing images + +## Troubleshooting + +### "buildx" is not a docker command + +Update Docker to a newer version that includes buildx, or install the buildx plugin. + +### Authentication error + +Run `docker login` and enter your Docker Hub credentials. + +### Build fails on specific platform + +You can modify the script to build for only one platform: + +```bash +# In publish-docker.sh, change: +--platform linux/amd64,linux/arm64 +# to: +--platform linux/amd64 +``` + +## Using Published Images + +After publishing, users can run ExcaliDash using the pre-built images: + +```bash +# Using production compose file (no build step needed) +docker compose -f docker-compose.prod.yml up -d + +# Or using regular compose file (will pull if not building) +docker compose pull +docker compose up -d +``` + +## CI/CD Integration + +You can integrate this script into your CI/CD pipeline: + +```yaml +# Example GitHub Actions workflow +- name: Build and Push Docker Images + env: + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + run: | + echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin + ./publish-docker.sh ${{ github.ref_name }} +``` diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..567bf5c --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,11 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +*.md +.env +.DS_Store +*.log +prisma/dev.db +prisma/dev.db-journal diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..da1157d --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,4 @@ +# Backend Environment Variables +PORT=8000 +NODE_ENV=production +DATABASE_URL=file:/app/prisma/dev.db diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..1635bd4 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,57 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +COPY tsconfig.json ./ + +# Install dependencies +RUN npm ci + +# Copy prisma schema +COPY prisma ./prisma/ + +# Generate Prisma Client +RUN npx prisma generate + +# Copy source code +COPY src ./src + +# Build TypeScript +RUN npx tsc + +# Production stage +FROM node:20-alpine + +# Install OpenSSL for Prisma +RUN apk add --no-cache openssl + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install production dependencies only +RUN npm ci --only=production + +# Copy prisma schema and migrations +COPY prisma ./prisma/ + +# Copy built application from builder +COPY --from=builder /app/dist ./dist + +# Copy the generated Prisma Client from builder to maintain the same structure +COPY --from=builder /app/src/generated ./dist/generated + +# Generate Prisma Client in production (updates node_modules) +RUN npx prisma generate + +# Run migrations and start server +COPY docker-entrypoint.sh ./ +RUN chmod +x docker-entrypoint.sh + +EXPOSE 8000 + +ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/backend/docker-entrypoint.sh b/backend/docker-entrypoint.sh new file mode 100644 index 0000000..b3ae59e --- /dev/null +++ b/backend/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +# Run migrations +npx prisma migrate deploy + +# Start the application +node dist/index.js diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 1a6ccfc..e4c370a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -2,8 +2,9 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema generator client { - provider = "prisma-client-js" - output = "../src/generated/client" + provider = "prisma-client-js" + output = "../src/generated/client" + binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"] } datasource db { diff --git a/backend/src/index.ts b/backend/src/index.ts index 40e0945..0671563 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -104,6 +104,11 @@ io.on("connection", (socket) => { }); }); +// Health check endpoint +app.get("/health", (req, res) => { + res.status(200).json({ status: "ok" }); +}); + // --- Drawings --- // GET /drawings diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 8eb77f9..615ac33 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -2,8 +2,8 @@ // Visit https://aka.ms/tsconfig to read more about this file "compilerOptions": { // File Layout - // "rootDir": "./src", - // "outDir": "./dist", + "rootDir": "./src", + "outDir": "./dist", // Environment Settings // See also https://aka.ms/tsconfig/module @@ -16,5 +16,7 @@ "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, - } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "prisma.config.ts"] } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..e128be9 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,55 @@ +services: + backend: + image: zimengxiong/excalidash-backend:latest + container_name: excalidash-backend + environment: + - DATABASE_URL=file:/app/prisma/dev.db + - PORT=8000 + - NODE_ENV=production + volumes: + - backend-data:/app/prisma + networks: + - excalidash-network + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:8000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))", + ] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + image: zimengxiong/excalidash-frontend:latest + container_name: excalidash-frontend + ports: + - "6767:80" + depends_on: + - backend + networks: + - excalidash-network + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--quiet", + "--tries=1", + "--spider", + "http://localhost:80", + ] + interval: 30s + timeout: 10s + retries: 3 + +networks: + excalidash-network: + driver: bridge + +volumes: + backend-data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..beab0c0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,59 @@ +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: excalidash-backend + environment: + - DATABASE_URL=file:/app/prisma/dev.db + - PORT=8000 + - NODE_ENV=production + volumes: + - backend-data:/app/prisma + networks: + - excalidash-network + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "require('http').get('http://localhost:8000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1))", + ] + interval: 30s + timeout: 10s + retries: 3 + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: excalidash-frontend + ports: + - "6767:80" + depends_on: + - backend + networks: + - excalidash-network + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--quiet", + "--tries=1", + "--spider", + "http://localhost:80", + ] + interval: 30s + timeout: 10s + retries: 3 + +networks: + excalidash-network: + driver: bridge + +volumes: + backend-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..80455a8 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,9 @@ +node_modules +npm-debug.log +dist +.git +.gitignore +*.md +.env +.DS_Store +*.log diff --git a/frontend/.env.example b/frontend/.env.example new file mode 100644 index 0000000..02530a5 --- /dev/null +++ b/frontend/.env.example @@ -0,0 +1,4 @@ +# Frontend Environment Variables +# Use /api for production (proxied by nginx) +# Use http://localhost:8000 for local development +VITE_API_URL=/api diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..e82c617 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_URL=/api diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..693ffee --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,29 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# Copy source code and config files +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy custom nginx config +COPY nginx.conf /etc/nginx/nginx.conf + +# Copy built application from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..6ad2f0c --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,58 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + keepalive_timeout 65; + gzip on; + gzip_vary on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # API and WebSocket proxy to backend + location /api/ { + proxy_pass http://backend:8000/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + 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; + } + + # WebSocket proxy for Socket.IO + location /socket.io/ { + proxy_pass http://backend:8000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + 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; + } + + # Frontend routes + location / { + try_files $uri $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } +} diff --git a/frontend/src/pages/Editor.tsx b/frontend/src/pages/Editor.tsx index 328c95c..7c6c9d1 100644 --- a/frontend/src/pages/Editor.tsx +++ b/frontend/src/pages/Editor.tsx @@ -99,8 +99,14 @@ export const Editor: React.FC = () => { useEffect(() => { if (!id || !isReady) return; - const socket = io(import.meta.env.VITE_API_URL || 'http://localhost:8000', { - transports: ['websocket'], + // For production/Docker, connect to same origin. For dev, use localhost:8000 + const socketUrl = import.meta.env.VITE_API_URL === '/api' + ? window.location.origin + : (import.meta.env.VITE_API_URL || 'http://localhost:8000'); + + const socket = io(socketUrl, { + path: '/socket.io', + transports: ['websocket', 'polling'], }); socketRef.current = socket; diff --git a/publish-docker.sh b/publish-docker.sh new file mode 100755 index 0000000..8990bac --- /dev/null +++ b/publish-docker.sh @@ -0,0 +1,77 @@ +#!/bin/bash +set -e + +# Configuration +DOCKER_USERNAME="zimengxiong" +IMAGE_NAME="excalidash" +VERSION=${1:-latest} + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}===========================================${NC}" +echo -e "${GREEN}ExcaliDash Multi-Platform Docker Builder${NC}" +echo -e "${GREEN}===========================================${NC}" +echo "" + +# Check if logged in to Docker Hub +echo -e "${YELLOW}Checking Docker Hub authentication...${NC}" +if ! docker info | grep -q "Username: $DOCKER_USERNAME"; then + echo -e "${YELLOW}Not logged in. Please login to Docker Hub:${NC}" + docker login +else + echo -e "${GREEN}✓ Already logged in as $DOCKER_USERNAME${NC}" +fi + +# Create buildx builder if it doesn't exist +echo -e "${YELLOW}Setting up buildx builder...${NC}" +if ! docker buildx inspect excalidash-builder > /dev/null 2>&1; then + echo -e "${YELLOW}Creating new buildx builder...${NC}" + docker buildx create --name excalidash-builder --use --bootstrap +else + echo -e "${GREEN}✓ Using existing buildx builder${NC}" + docker buildx use excalidash-builder +fi + +# Build and push backend image +echo "" +echo -e "${GREEN}Building and pushing backend image...${NC}" +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag $DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION \ + --tag $DOCKER_USERNAME/$IMAGE_NAME-backend:latest \ + --file backend/Dockerfile \ + --push \ + backend/ + +echo -e "${GREEN}✓ Backend image pushed successfully${NC}" + +# Build and push frontend image +echo "" +echo -e "${GREEN}Building and pushing frontend image...${NC}" +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --tag $DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION \ + --tag $DOCKER_USERNAME/$IMAGE_NAME-frontend:latest \ + --file frontend/Dockerfile \ + --push \ + frontend/ + +echo -e "${GREEN}✓ Frontend image pushed successfully${NC}" + +# Summary +echo "" +echo -e "${GREEN}===========================================${NC}" +echo -e "${GREEN}✓ All images published successfully!${NC}" +echo -e "${GREEN}===========================================${NC}" +echo "" +echo -e "Published images:" +echo -e " Backend: ${YELLOW}$DOCKER_USERNAME/$IMAGE_NAME-backend:$VERSION${NC}" +echo -e " Frontend: ${YELLOW}$DOCKER_USERNAME/$IMAGE_NAME-frontend:$VERSION${NC}" +echo "" +echo -e "To use these images:" +echo -e " ${YELLOW}docker compose -f docker-compose.prod.yml up -d${NC}" +echo ""