diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..da7bb8e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +node_modules +dist +dist-ssr +*.local +.git +.gitignore +*.md +.env +.env.local +.vscode +.idea +*.log +npm-debug.log* +bills.db +tmp diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..653950b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Frontend Dockerfile - Multi-stage build for lightweight image +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# Accept build arguments for API keys +ARG GEMINI_API_KEY +ARG OPENAI_API_KEY +ARG ANTHROPIC_API_KEY +ARG OLLAMA_HOST +ARG OLLAMA_MODEL +ARG OPENAI_MODEL +ARG ANTHROPIC_MODEL +ARG AI_SERVICE + +# Set as environment variables for the build +ENV GEMINI_API_KEY=$GEMINI_API_KEY +ENV OPENAI_API_KEY=$OPENAI_API_KEY +ENV ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY +ENV OLLAMA_HOST=$OLLAMA_HOST +ENV OLLAMA_MODEL=$OLLAMA_MODEL +ENV OPENAI_MODEL=$OPENAI_MODEL +ENV ANTHROPIC_MODEL=$ANTHROPIC_MODEL +ENV AI_SERVICE=$AI_SERVICE +# Copy source files +COPY . . + +# Build the application +RUN npm run build + +# Production stage - serve with lightweight nginx +FROM nginx:alpine + +# Copy built assets from builder stage +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy custom nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port 80 +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index 75f93f6..183b25e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ BillScan consists of two parts: ## Prerequisites -- Node.js (v18+ recommended) +- Node.js (v18+ recommended) - for local development +- Docker and Docker Compose - for containerized deployment - API key for at least one AI service (see AI Service Configuration below) ## AI Service Configuration @@ -49,6 +50,40 @@ BillScan supports multiple AI services for bill/receipt extraction. Configure wh If `AI_SERVICE` is not set, the application defaults to `gemini` for backward compatibility. +## Quick Start with Docker + +The easiest way to run BillScan is with Docker Compose: + +1. Create a `.env` file in the project root with your API keys: + ```bash + # .env file + GEMINI_API_KEY=your_key_here + AI_SERVICE=gemini + # Add other keys as needed (see AI Service Configuration above) + ``` + +2. Build and start the containers: + ```bash + docker compose up --build + ``` + +3. Access the application: + - Frontend: http://localhost:8080 + - Backend API: http://localhost:8080/api (proxied through frontend) + +4. To stop the containers: + ```bash + docker compose down + ``` + +**Note:** The SQLite database is persisted in a Docker volume (`billscan-data`), so your data will be preserved across container restarts. + +### Docker Architecture + +- **Frontend**: Multi-stage build using Node.js for building and nginx:alpine for serving (~30MB final image) +- **Backend**: Node.js Alpine image running Express server (~150MB) +- **Data Persistence**: SQLite database stored in a named Docker volume + ## Setup & Run (Frontend) From the project root: @@ -146,3 +181,10 @@ The application uses a pluggable AI service architecture: - If the server fails to start, ensure port 3000 is free. - Delete `server/bills.db` to reset stored data (schema recreated automatically). - Large image uploads: body size limit set to `50mb`; adjust in `server/index.js` if needed. + +### Docker Troubleshooting + +- If Docker build fails, ensure you have Docker and Docker Compose installed. +- To reset Docker data: `docker compose down -v` (removes volumes and data). +- View container logs: `docker compose logs -f` +- Rebuild containers after code changes: `docker compose up --build` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..da8b045 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + frontend: + build: + context: . + dockerfile: Dockerfile + args: + - GEMINI_API_KEY=${GEMINI_API_KEY} + - OPENAI_API_KEY=${OPENAI_API_KEY} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + - OLLAMA_HOST=${OLLAMA_HOST} + - OLLAMA_MODEL=${OLLAMA_MODEL} + - OPENAI_MODEL=${OPENAI_MODEL} + - ANTHROPIC_MODEL=${ANTHROPIC_MODEL} + - AI_SERVICE=${AI_SERVICE} + ports: + - "8080:80" + depends_on: + backend: + condition: service_healthy + environment: + - NODE_ENV=production + restart: unless-stopped + + backend: + build: + context: ./server + dockerfile: Dockerfile + volumes: + - billscan-data:/data + environment: + - NODE_ENV=production + - DATA_DIR=/data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/api/bills"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + restart: unless-stopped + +volumes: + billscan-data: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..ddb0223 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,41 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Enable gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Handle SPA routing - redirect all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api { + proxy_pass http://backend:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + 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; + proxy_cache_bypass $http_upgrade; + # Increase body size limit for image uploads + client_max_body_size 50M; + } + + # 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/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..4245c32 --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1,8 @@ +node_modules +*.log +npm-debug.log* +bills.db +.git +.gitignore +*.md +tmp diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..621fbb1 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,28 @@ +# Backend Dockerfile - Lightweight Node.js image +FROM node:22-alpine + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN if [ -f package-lock.json ]; then npm ci --production; else npm install --production; fi + +# Copy source files +COPY . . + +# Create data directory for SQLite database persistence +RUN mkdir -p /data + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001 +RUN chown -R nodejs:nodejs /app /data + +# Switch to non-root user +USER nodejs +# Expose port 3000 +EXPOSE 3000 + +# Start the server +CMD ["node", "index.js"] diff --git a/server/db.js b/server/db.js index 4eab494..89b9c96 100644 --- a/server/db.js +++ b/server/db.js @@ -6,8 +6,10 @@ let dbInstance = null; async function getDb() { if (!dbInstance) { + // Use DATA_DIR environment variable if set, otherwise use current directory + const dataDir = process.env.DATA_DIR || __dirname; dbInstance = await open({ - filename: path.join(__dirname, 'bills.db'), + filename: path.join(dataDir, 'bills.db'), driver: sqlite3.Database });