diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6957864 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.env +.env.local +.env.*.local +.git +.gitignore +plans +docs +*.md +!README.md diff --git a/.env.example b/.env.example index 0bec4a1..39c5a90 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# Docker — port exposed on host (default: 3000) +APP_PORT=3000 + # Supabase — https://supabase.com/dashboard/project/_/settings/api VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_PUBLISHABLE_KEY=sb_publishable_... diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a1aebf1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,42 @@ +# ============================================================ +# Stage 1 — Build +# Node 22 Alpine: smallest image with full npm support +# ============================================================ +FROM node:22-alpine AS builder + +WORKDIR /app + +# Install dependencies first (layer cache — only re-runs if package.json changes) +COPY package.json package-lock.json ./ +RUN npm ci + +# Copy source +COPY . . + +# VITE_* vars are Supabase public keys — safe for browser, baked into bundle at build time. +# Using ARG only (no ENV) so values don't persist as image-layer env vars. +ARG VITE_SUPABASE_URL +ARG VITE_SUPABASE_ANON_KEY +ARG VITE_SUPABASE_PUBLISHABLE_KEY + +# Pass vars inline so they're scoped to this RUN layer only +RUN VITE_SUPABASE_URL="$VITE_SUPABASE_URL" \ + VITE_SUPABASE_ANON_KEY="$VITE_SUPABASE_ANON_KEY" \ + VITE_SUPABASE_PUBLISHABLE_KEY="$VITE_SUPABASE_PUBLISHABLE_KEY" \ + npm run build + +# ============================================================ +# Stage 2 — Serve +# Nginx Alpine: ~25MB final image +# ============================================================ +FROM nginx:alpine AS runner + +# Replace default nginx config with SPA config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Copy built static files from builder +COPY --from=builder /app/dist /usr/share/nginx/html + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker-build.sh b/docker-build.sh new file mode 100755 index 0000000..c38f1d0 --- /dev/null +++ b/docker-build.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# Build multi-platform Docker image (linux/amd64 + linux/arm64) +# Usage: +# ./docker-build.sh → build + load locally (single platform) +# ./docker-build.sh --push → build + push to Docker Hub + +set -euo pipefail + +# ── Config ──────────────────────────────────────────────────── +IMAGE_NAME="renolation/english-toeic" +IMAGE_TAG="${IMAGE_TAG:-latest}" +REGISTRY="${REGISTRY:-}" +PLATFORMS="linux/amd64,linux/arm64" +BUILDER_NAME="multiarch-builder" +ENV_FILE=".env" + +# ── Load env vars from .env ─────────────────────────────────── +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ $ENV_FILE not found. Copy .env.example and fill in values." + exit 1 +fi + +# Load all vars from .env (set -a auto-exports every assignment) +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${VITE_SUPABASE_URL:?VITE_SUPABASE_URL is required in .env}" +: "${VITE_SUPABASE_ANON_KEY:?VITE_SUPABASE_ANON_KEY is required in .env}" + +FULL_IMAGE="${IMAGE_NAME}:${IMAGE_TAG}" + +# ── Parse flags ─────────────────────────────────────────────── +PUSH=false +for arg in "$@"; do + case $arg in + --push) PUSH=true ;; + esac +done + +echo "🔧 Image : $FULL_IMAGE" +echo "🖥️ Platforms: $PLATFORMS" +echo "" + +# ── Ensure buildx builder exists ───────────────────────────── +if ! docker buildx inspect "$BUILDER_NAME" &>/dev/null; then + echo "📦 Creating buildx builder: $BUILDER_NAME" + docker buildx create --name "$BUILDER_NAME" --driver docker-container --bootstrap +fi +docker buildx use "$BUILDER_NAME" + +# ── Build ───────────────────────────────────────────────────── +BUILD_ARGS=( + --platform "$PLATFORMS" + --build-arg "VITE_SUPABASE_URL=${VITE_SUPABASE_URL}" + --build-arg "VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}" + --build-arg "VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY:-}" + --tag "$FULL_IMAGE" + --file Dockerfile +) + +if $PUSH; then + echo "🚀 Building and pushing to registry..." + docker buildx build "${BUILD_ARGS[@]}" --push . + echo "" + echo "✅ Pushed: $FULL_IMAGE" +else + # --load only works for single platform; build amd64 locally by default + echo "🏗️ Building for local use (linux/amd64)..." + docker buildx build \ + --platform linux/amd64 \ + --build-arg "VITE_SUPABASE_URL=${VITE_SUPABASE_URL}" \ + --build-arg "VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}" \ + --build-arg "VITE_SUPABASE_PUBLISHABLE_KEY=${VITE_SUPABASE_PUBLISHABLE_KEY:-}" \ + --tag "$FULL_IMAGE" \ + --file Dockerfile \ + --load \ + . + echo "" + echo "✅ Built locally: $FULL_IMAGE" + echo " Run with: docker compose up (or) docker run -p 3000:80 $FULL_IMAGE" +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4159f30 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile + args: + VITE_SUPABASE_URL: ${VITE_SUPABASE_URL} + VITE_SUPABASE_ANON_KEY: ${VITE_SUPABASE_ANON_KEY} + VITE_SUPABASE_PUBLISHABLE_KEY: ${VITE_SUPABASE_PUBLISHABLE_KEY} + image: renolation/english-toeic:latest + ports: + - "${APP_PORT:-3000}:80" + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost/health"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..02184b8 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml; + gzip_min_length 1024; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA fallback — all routes serve index.html + location / { + try_files $uri $uri/ /index.html; + } + + # Health check endpoint + location /health { + return 200 "ok"; + add_header Content-Type text/plain; + } +}