diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6083de1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,44 @@ +# Version control +.git +.gitignore +.github + +# Node.js +node_modules +npm-debug.log +yarn-debug.log +yarn-error.log + +# Development files +*.md +!README.md +!CHANGELOG.md +*.log +*.bak +.DS_Store +.vscode +.idea +.editorconfig +.eslintrc +.prettierrc + +# Test files +test +__tests__ +coverage + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# Local data (should be mounted as volume) +data/* +!data/.gitkeep + +# Debug scripts +debug-*.js +test-*.js + +# Documentation +docs diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..4a4820e --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,55 @@ +name: Build and Push Docker Image + +on: + push: + branches: [ main ] + tags: [ 'v*' ] + pull_request: + branches: [ main ] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: nmemmert/spelling + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=ref,event=branch + type=sha,format=long + latest + + - name: Build and push Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile index 58b89e1..1349afe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,25 +1,47 @@ # ===== STAGE 1: Dependency Installation ===== -FROM node:18 AS deps +FROM node:20-slim AS deps WORKDIR /app +# Copy package files and install dependencies COPY package*.json ./ RUN npm ci --omit=dev -COPY . . - # ===== STAGE 2: Runtime ===== -FROM node:18-slim +FROM node:20-slim +# Install tini for proper signal handling RUN apt-get update && apt-get install -y --no-install-recommends \ tini \ && rm -rf /var/lib/apt/lists/* +# Create non-root user for better security +RUN groupadd -r spelling && useradd -r -g spelling spelling + +# Setup application directory WORKDIR /app -COPY --from=deps /app /app +# Copy node modules and package files +COPY --from=deps /app/node_modules /app/node_modules +COPY --from=deps /app/package*.json /app/ +# Copy application code +COPY . . + +# Create and set permissions for data directory +RUN mkdir -p /app/data && chown -R spelling:spelling /app/data + +# Switch to non-root user +USER spelling + +# Set version label +LABEL version="1.1.0" \ + description="Spelling Practice Application" \ + maintainer="nmemmert" + +# Expose application port EXPOSE 3000 +# Use tini as init process and start application ENTRYPOINT ["/usr/bin/tini", "--"] CMD ["node", "server.js"] diff --git a/Readme.md b/Readme.md index 1f824ef..c00458e 100644 --- a/Readme.md +++ b/Readme.md @@ -40,10 +40,13 @@ docker run -d \ -p 3000:3000 \ -v ./data:/app/data \ --restart unless-stopped \ -ghcr.io/nmemmert/spelling:latest + --health-cmd "wget -q -O - http://localhost:3000/health || exit 1" \ + --health-interval 30s \ + --health-retries 3 \ + ghcr.io/nmemmert/spelling:latest ``` -#### Custom Docker Compose +#### Advanced Docker Compose ```yaml version: "3.9" services: @@ -54,9 +57,31 @@ services: - "3000:3000" volumes: - ./data:/app/data - restart: unless-stopped environment: - NODE_ENV=production + # Optional environment variables: + # - PORT=3000 + # - HOST=0.0.0.0 + restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', res => res.statusCode === 200 ? process.exit(0) : process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s +``` + +#### Building Your Own Docker Image +```bash +# Clone the repository +git clone https://github.com/nmemmert/spelling.git +cd spelling + +# Build the Docker image +docker build -t spelling:local . + +# Run your local image +docker run -d --name spelling-app -p 3000:3000 -v ./data:/app/data spelling:local ``` ### Option 2: CasaOS Installation diff --git a/build-push-docker.ps1 b/build-push-docker.ps1 new file mode 100644 index 0000000..fef805d --- /dev/null +++ b/build-push-docker.ps1 @@ -0,0 +1,51 @@ +# PowerShell script to build and push Docker image for Spelling Practice App + +# Set variables +$IMAGE_NAME = "ghcr.io/nmemmert/spelling" +$VERSION = (Get-Content -Path "version.json" | ConvertFrom-Json).version + +Write-Host "๐Ÿ”จ Building Docker image ${IMAGE_NAME}:${VERSION}..." -ForegroundColor Cyan + +# Build the image +docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest . + +Write-Host "โœ… Build completed." -ForegroundColor Green +Write-Host "๐Ÿงช Testing the image..." -ForegroundColor Cyan + +# Run container for testing +docker run --name spelling-test -d -p 3001:3000 ${IMAGE_NAME}:${VERSION} + +# Wait for container to start +Start-Sleep -Seconds 5 + +# Test health endpoint +try { + $response = Invoke-WebRequest -Uri "http://localhost:3001/health" -UseBasicParsing + if ($response.StatusCode -eq 200) { + Write-Host "โœ… Health check passed!" -ForegroundColor Green + } else { + Write-Host "โŒ Health check failed with status $($response.StatusCode)" -ForegroundColor Red + docker logs spelling-test + docker stop spelling-test + docker rm spelling-test + exit 1 + } +} catch { + Write-Host "โŒ Health check failed: $_" -ForegroundColor Red + docker logs spelling-test + docker stop spelling-test + docker rm spelling-test + exit 1 +} + +# Stop and remove test container +docker stop spelling-test +docker rm spelling-test + +Write-Host "๐Ÿ“ค Pushing image to GitHub Container Registry..." -ForegroundColor Cyan +docker push ${IMAGE_NAME}:${VERSION} +docker push ${IMAGE_NAME}:latest + +Write-Host "๐Ÿš€ Successfully built and pushed ${IMAGE_NAME}:${VERSION}" -ForegroundColor Green +Write-Host "Press any key to exit..." +$null = $host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") diff --git a/build-push-docker.sh b/build-push-docker.sh new file mode 100644 index 0000000..28cd08a --- /dev/null +++ b/build-push-docker.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Build and push Docker image for Spelling Practice App + +# Set variables +IMAGE_NAME="ghcr.io/nmemmert/spelling" +VERSION=$(grep -o '"version": "[^"]*"' version.json | cut -d'"' -f4) + +echo "๐Ÿ”จ Building Docker image ${IMAGE_NAME}:${VERSION}..." + +# Build the image +docker build -t ${IMAGE_NAME}:${VERSION} -t ${IMAGE_NAME}:latest . + +echo "โœ… Build completed." +echo "๐Ÿงช Testing the image..." + +# Run container for testing +docker run --name spelling-test -d -p 3001:3000 ${IMAGE_NAME}:${VERSION} + +# Wait for container to start +sleep 5 + +# Test health endpoint +HEALTH_STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001/health) + +if [ "$HEALTH_STATUS" = "200" ]; then + echo "โœ… Health check passed!" +else + echo "โŒ Health check failed with status $HEALTH_STATUS" + docker logs spelling-test + docker stop spelling-test + docker rm spelling-test + exit 1 +fi + +# Stop and remove test container +docker stop spelling-test +docker rm spelling-test + +echo "๐Ÿ“ค Pushing image to GitHub Container Registry..." +docker push ${IMAGE_NAME}:${VERSION} +docker push ${IMAGE_NAME}:latest + +echo "๐Ÿš€ Successfully built and pushed ${IMAGE_NAME}:${VERSION}" diff --git a/docker-compose.yml b/docker-compose.yml index f9c84ab..058d300 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,11 +2,21 @@ version: "3.9" services: spelling-app: - build: . + build: + context: . + dockerfile: Dockerfile + image: ghcr.io/nmemmert/spelling:1.1.0 container_name: spelling-app ports: - "3000:3000" volumes: - - ./public/users.json:/app/public/users.json - - ./public/wordlists.json:/app/public/wordlists.json + - ./data:/app/data + environment: + - NODE_ENV=production restart: unless-stopped + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', res => res.statusCode === 200 ? process.exit(0) : process.exit(1))"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s diff --git a/server.js b/server.js index e1101e6..de2f0ee 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,37 @@ const files = { app.use(express.static('public')); app.use(express.json()); +// Health check endpoint for container monitoring +app.get('/health', (req, res) => { + try { + // Check if essential files exist + const usersExists = fs.existsSync(path.join(DATA_DIR, files.users)); + const wordlistsExists = fs.existsSync(path.join(DATA_DIR, files.wordlists)); + + if (usersExists && wordlistsExists) { + res.status(200).json({ + status: 'ok', + timestamp: new Date().toISOString(), + version: require('./version.json').version + }); + } else { + res.status(503).json({ + status: 'error', + message: 'Essential data files not available', + missing: { + users: !usersExists, + wordlists: !wordlistsExists + } + }); + } + } catch (error) { + res.status(500).json({ + status: 'error', + message: error.message + }); + } +}); + // Define routes after middleware is set up // Serve raw wordlists.json for admin UI app.get('/getWordlistsRaw', (req, res) => { @@ -1145,9 +1176,27 @@ app.get('/checkBadges', (req, res) => { // Initialize all required data files ensureAllRequiredFiles(); -// ๐Ÿš€ Server start -app.listen(PORT, () => { - console.log(`โœ… Server listening at http://localhost:${PORT}`); +// Get environment variables or use defaults +const HOST = process.env.HOST || '0.0.0.0'; +const PORT_ENV = process.env.PORT || PORT; + +// Handle graceful shutdown for container orchestration +process.on('SIGTERM', () => { + console.log('๐Ÿ›‘ SIGTERM received. Shutting down gracefully...'); + process.exit(0); +}); + +process.on('SIGINT', () => { + console.log('๏ฟฝ SIGINT received. Shutting down gracefully...'); + process.exit(0); +}); + +// ๏ฟฝ๐Ÿš€ Server start +app.listen(PORT_ENV, HOST, () => { + console.log(`โœ… Server listening at http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT_ENV}`); + console.log(`๐Ÿ“ Data directory: ${DATA_DIR}`); + console.log(`๐ŸŒ Node environment: ${process.env.NODE_ENV || 'development'}`); + console.log(`๐Ÿ“ฆ Version: ${require('./version.json').version}`); // Display available users for easy login try {