From 27ed7ec0302c65ebba06911d855fd2796fca0f43 Mon Sep 17 00:00:00 2001 From: Ndacyayisenga-droid Date: Wed, 21 Jan 2026 17:38:47 +0300 Subject: [PATCH] Combine Synkronus API and Portal into a single Docker image with Nginx serving the frontend and proxying API requests --- .github/workflows/synkronus-docker.yml | 20 ++- .github/workflows/synkronus-portal-docker.yml | 100 ----------- DOCKER.md | 40 +++++ Dockerfile | 163 ++++++++++++++++++ docker-compose.yml | 81 +++++++++ synkronus-portal/vite.config.ts | 29 +++- 6 files changed, 323 insertions(+), 110 deletions(-) delete mode 100644 .github/workflows/synkronus-portal-docker.yml create mode 100644 DOCKER.md create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.github/workflows/synkronus-docker.yml b/.github/workflows/synkronus-docker.yml index c4dec91b3..df1fbce52 100644 --- a/.github/workflows/synkronus-docker.yml +++ b/.github/workflows/synkronus-docker.yml @@ -1,4 +1,4 @@ -name: Synkronus Docker Build & Publish +name: Synkronus & Portal Docker Build & Publish on: push: @@ -7,10 +7,16 @@ on: - dev paths: - 'synkronus/**' + - 'synkronus-portal/**' + - 'packages/**' + - 'Dockerfile' - '.github/workflows/synkronus-docker.yml' pull_request: paths: - 'synkronus/**' + - 'synkronus-portal/**' + - 'packages/**' + - 'Dockerfile' - '.github/workflows/synkronus-docker.yml' workflow_dispatch: inputs: @@ -23,11 +29,11 @@ on: env: REGISTRY: ghcr.io - IMAGE_NAME: opendataensemble/synkronus jobs: build-and-push: runs-on: ubuntu-latest + name: Build and push combined Synkronus image permissions: contents: write packages: write @@ -57,7 +63,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ env.REGISTRY }}/opendataensemble/synkronus tags: | # For main branch: latest + version tag (manual dispatch) or release tag type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} @@ -75,15 +81,15 @@ jobs: type=sha,prefix=sha-,enable=${{ github.event_name != 'release' && github.event_name != 'pull_request' }} labels: | org.opencontainers.image.title=Synkronus - org.opencontainers.image.description=Synchronization API for offline-first applications + org.opencontainers.image.description=Synchronization API and Web Portal for offline-first applications org.opencontainers.image.vendor=Open Data Ensemble - name: Build and push Docker image id: build uses: docker/build-push-action@v5 with: - context: ./synkronus - file: ./synkronus/Dockerfile + context: . + file: ./Dockerfile platforms: linux/amd64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} @@ -95,6 +101,6 @@ jobs: if: github.event_name != 'pull_request' uses: actions/attest-build-provenance@v1 with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + subject-name: ${{ env.REGISTRY }}/opendataensemble/synkronus subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: true diff --git a/.github/workflows/synkronus-portal-docker.yml b/.github/workflows/synkronus-portal-docker.yml deleted file mode 100644 index 0f820c4c3..000000000 --- a/.github/workflows/synkronus-portal-docker.yml +++ /dev/null @@ -1,100 +0,0 @@ -name: Synkronus Portal Docker Build & Publish - -on: - push: - branches: - - main - - dev - paths: - - 'synkronus-portal/**' - - '.github/workflows/synkronus-portal-docker.yml' - pull_request: - paths: - - 'synkronus-portal/**' - - '.github/workflows/synkronus-portal-docker.yml' - workflow_dispatch: - inputs: - version: - description: 'Version tag (e.g., v1.0.0). If empty, tags will be derived from the current ref.' - required: false - type: string - release: - types: [published] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: opendataensemble/synkronus-portal - -jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: write - packages: write - id-token: write - attestations: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - with: - platforms: all - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - # For main branch: latest + version tag (manual dispatch) or release tag - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - # When triggered via manual dispatch with a version input - type=semver,pattern=v{{version}},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' }},value=${{ github.event.inputs.version }} - type=semver,pattern=v{{major}}.{{minor}},enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.version != '' }},value=${{ github.event.inputs.version }} - # When triggered from a GitHub Release event, use the release tag name as the semver source - type=semver,pattern=v{{version}},enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }} - type=semver,pattern=v{{major}}.{{minor}},enable=${{ github.event_name == 'release' }},value=${{ github.event.release.tag_name }} - # For other branches: branch name (pre-release) - type=ref,event=branch,enable=${{ github.event_name != 'release' && github.ref != 'refs/heads/main' }} - # For PRs: pr-number - type=ref,event=pr - # SHA for traceability (only for non-release events) - type=sha,prefix=sha-,enable=${{ github.event_name != 'release' && github.event_name != 'pull_request' }} - labels: | - org.opencontainers.image.title=Synkronus Portal - org.opencontainers.image.description=Synchronization API for offline-first applications - org.opencontainers.image.vendor=Open Data Ensemble - - - name: Build and push Docker image - id: build - uses: docker/build-push-action@v5 - with: - context: . - file: ./synkronus-portal/Dockerfile - platforms: linux/amd64 - 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 - - - name: Generate artifact attestation - if: github.event_name != 'pull_request' - uses: actions/attest-build-provenance@v1 - with: - subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - subject-digest: ${{ steps.build.outputs.digest }} - push-to-registry: true diff --git a/DOCKER.md b/DOCKER.md new file mode 100644 index 000000000..edc7d8e07 --- /dev/null +++ b/DOCKER.md @@ -0,0 +1,40 @@ +# Docker Build and Run Guide + +This guide explains how to build and run the combined Synkronus Docker image, which includes both the Synkronus API backend and the Portal frontend in a single container. + +## Prerequisites + +- Docker and Docker Compose installed +- PostgreSQL database (run as separate container or use existing database) + +## Quick Start with Docker Compose (Recommended) + +1. **Update credentials** in `docker-compose.yml`: + - `POSTGRES_PASSWORD`: Change `your_password` + - `DB_CONNECTION`: Update password in connection string + - `JWT_SECRET`: Generate with `openssl rand -base64 32` + +2. **Start all services**: + ```bash + docker compose up -d + ``` + + +## Docker Compose Commands + +```bash +# Start services +docker compose up -d + +# View logs +docker compose logs -f + +# Check status +docker compose ps + +# Stop services +docker compose down + +# Stop and remove volumes (WARNING: deletes data) +docker compose down -v +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..334fb1da5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,163 @@ +# Multi-stage build for combined Synkronus + Portal +# Stage 1: Build the Go application (Synkronus) +FROM golang:1.24.2-alpine AS synkronus-builder + +# Install build dependencies +RUN apk add --no-cache git + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY synkronus/go.mod synkronus/go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY synkronus/ . + +# Build the application +ENV CGO_ENABLED=0 GOOS=linux +RUN go build -a -ldflags='-w -s' -o synkronus ./cmd/synkronus + +# Stage 2: Build the React application (Portal) +FROM node:20-alpine AS portal-builder + +WORKDIR /app + +# Copy package files for all packages (monorepo structure) +COPY packages/tokens/package*.json ./packages/tokens/ +COPY packages/tokens/package-lock.json ./packages/tokens/ +COPY packages/tokens/style-dictionary.config.js ./packages/tokens/ +COPY packages/tokens/config.json ./packages/tokens/ +COPY packages/tokens/src ./packages/tokens/src +COPY packages/components/package*.json ./packages/components/ +COPY synkronus-portal/package*.json ./synkronus-portal/ +COPY synkronus-portal/package-lock.json ./synkronus-portal/ + +# Install dependencies for tokens +WORKDIR /app/packages/tokens +RUN npm ci + +# Install dependencies for components +WORKDIR /app/packages/components +RUN npm install + +# Install dependencies for portal +WORKDIR /app/synkronus-portal +RUN npm ci + +# Copy source code for all packages +WORKDIR /app +COPY packages/tokens ./packages/tokens +COPY packages/components ./packages/components +COPY synkronus-portal ./synkronus-portal + +# Build tokens first (if needed) +WORKDIR /app/packages/tokens +RUN npm run build || true + +# Build components (if needed) +WORKDIR /app/packages/components +RUN npm run build || true + +# Build the portal application +WORKDIR /app/synkronus-portal +RUN npm run build + +# Stage 3: Combine both in final image +FROM nginx:alpine + +# Install runtime dependencies for synkronus (wget for healthcheck, su-exec for user switching) +RUN apk --no-cache add ca-certificates tzdata wget su-exec + +# Create non-root user for synkronus +RUN addgroup -g 1000 synkronus && \ + adduser -D -u 1000 -G synkronus synkronus + +# Copy synkronus binary and assets from builder +WORKDIR /app +COPY --from=synkronus-builder /app/synkronus /app/synkronus +COPY --from=synkronus-builder /app/openapi /app/openapi +COPY --from=synkronus-builder /app/static /app/static + +# Create directories for data storage with proper permissions +RUN mkdir -p /app/data/app-bundles && \ + chown -R synkronus:synkronus /app + +# Copy portal built assets +COPY --from=portal-builder /app/synkronus-portal/dist /usr/share/nginx/html + +# Create nginx configuration +# Proxy /api requests to local synkronus backend on port 8080 +RUN echo 'server { \ + listen 0.0.0.0:80; \ + listen [::]:80; \ + server_name _; \ + root /usr/share/nginx/html; \ + index index.html; \ + \ + # Serve portal frontend \ + location / { \ + try_files $uri $uri/ /index.html; \ + } \ + \ + # Proxy API requests to local synkronus backend \ + location /api { \ + rewrite ^/api(.*)$ $1 break; \ + proxy_pass http://127.0.0.1:8080; \ + 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_set_header X-Forwarded-Host $host; \ + proxy_set_header Authorization $http_authorization; \ + proxy_pass_request_headers on; \ + proxy_connect_timeout 60s; \ + proxy_send_timeout 60s; \ + proxy_read_timeout 60s; \ + proxy_buffering off; \ + client_max_body_size 100M; \ + } \ +}' > /etc/nginx/conf.d/default.conf + +# Create startup script to run both services +RUN printf '#!/bin/sh\n\ +set -e\n\ +\n\ +# Function to handle shutdown\n\ +cleanup() {\n\ + echo "Shutting down..."\n\ + kill -TERM "$synkronus_pid" 2>/dev/null || true\n\ + nginx -s quit 2>/dev/null || true\n\ + wait "$synkronus_pid" 2>/dev/null || true\n\ + exit 0\n\ +}\n\ +\n\ +# Trap signals for graceful shutdown\n\ +trap cleanup SIGTERM SIGINT\n\ +\n\ +# Start synkronus in background as non-root user\n\ +su-exec synkronus /app/synkronus &\n\ +synkronus_pid=$!\n\ +\n\ +# Wait a moment for synkronus to start\n\ +sleep 2\n\ +\n\ +# Start nginx in foreground (this blocks, shell remains PID 1 for signal handling)\n\ +nginx -g "daemon off;"\n\ +' > /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh + +EXPOSE 80 + +# Health check - check synkronus health endpoint through nginx proxy +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ + CMD wget --no-verbose --tries=1 -O - http://127.0.0.1/api/health || exit 1 + +# Use custom entrypoint to run both services +ENTRYPOINT ["/docker-entrypoint.sh"] + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..b1313727d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,81 @@ +# Docker Compose configuration for Synkronus +# This setup includes: +# - PostgreSQL database (separate container) +# - Synkronus API + Portal (combined container) +# +# Usage: +# docker compose up -d # Start all services +# docker compose down # Stop all services +# docker compose logs -f # View logs +# docker compose ps # Check service status + +services: + # PostgreSQL database + postgres: + image: postgres:17 + container_name: synkronus-postgres + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: your_password # CHANGE THIS IN PRODUCTION! + POSTGRES_DB: synkronus + volumes: + - postgres-data:/var/lib/postgresql/data + expose: + - "5432" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - synkronus-network + + # Synkronus API + Portal (combined container) + synkronus: + build: + context: . + dockerfile: Dockerfile + container_name: synkronus + ports: + - "8080:80" # Map host port 8080 to container port 80 (nginx) + environment: + # Database connection - must match postgres service credentials + DB_CONNECTION: "postgres://postgres:your_password@postgres:5432/synkronus?sslmode=disable" + # JWT Secret (MUST be changed in production!) + # Generate with: openssl rand -base64 32 + JWT_SECRET: "your-secret-key-minimum-32-characters-long" # CHANGE THIS IN PRODUCTION! + PORT: "8080" # Internal port for Synkronus API + LOG_LEVEL: "info" # Options: debug, info, warn, error + volumes: + - app-bundles:/app/data/app-bundles + depends_on: + postgres: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "-O", "-", "http://127.0.0.1/api/health"] + interval: 30s + timeout: 3s + retries: 3 + start_period: 10s + networks: + - synkronus-network + +# Named volumes for data persistence +# These volumes persist even when containers are stopped or removed +# They are only deleted if you explicitly use: docker compose down -v +volumes: + postgres-data: + driver: local + # This volume stores all PostgreSQL database data + # It persists across container restarts and mode switches + app-bundles: + driver: local + # This volume stores uploaded app bundle ZIP files + # It persists across container restarts and mode switches + +networks: + synkronus-network: + driver: bridge + diff --git a/synkronus-portal/vite.config.ts b/synkronus-portal/vite.config.ts index 1e2b4e786..394f39955 100644 --- a/synkronus-portal/vite.config.ts +++ b/synkronus-portal/vite.config.ts @@ -6,13 +6,36 @@ export default defineConfig({ plugins: [ react({ babel: { - plugins: [['babel-plugin-react-compiler']], + // Disable React Compiler - it causes issues in production builds + // The compiler is experimental and can break React internals + plugins: [], }, }), ], - // Disable caching in development to prevent stale code issues + // Ensure React is properly resolved and deduplicated + resolve: { + dedupe: ['react', 'react-dom'], + }, optimizeDeps: { - force: true, // Force re-optimization of dependencies + include: ['react', 'react-dom'], + }, + build: { + // Ensure React is not bundled multiple times + commonjsOptions: { + include: [/node_modules/], + transformMixedEsModules: true, + }, + rollupOptions: { + output: { + // Let Vite handle chunking automatically for proper module resolution + manualChunks: undefined, + }, + }, + // Ensure proper module format + target: 'esnext', + modulePreload: { + polyfill: true, + }, }, server: { host: '0.0.0.0', // Allow external connections (needed for Docker)