#!/usr/bin/env bash # ============================================================================= # 🐳 Build Production Docker Images for Plesk Deployment # ============================================================================= # Features: # - Parallel builds with BuildKit # - Multi-platform support (amd64/arm64) # - Compressed tarballs with SHA256 checksums # - Buildx builder for cross-platform builds # - Intelligent layer caching # ============================================================================= set -Eeuo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # ============================================================================= # Configuration (override via env vars or flags) # ============================================================================= IMAGE_FRONTEND="${IMAGE_FRONTEND_NAME:-portal-frontend}" IMAGE_BACKEND="${IMAGE_BACKEND_NAME:-portal-backend}" IMAGE_TAG="${IMAGE_TAG:-}" OUTPUT_DIR="${OUTPUT_DIR:-$PROJECT_ROOT}" PUSH_REMOTE="${PUSH_REMOTE:-}" PARALLEL="${PARALLEL_BUILD:-1}" COMPRESS="${COMPRESS:-1}" USE_LATEST_FILENAME="${USE_LATEST_FILENAME:-1}" SAVE_TARS=1 PLATFORM="${PLATFORM:-linux/amd64}" PROGRESS="${PROGRESS:-auto}" USE_BUILDX="${USE_BUILDX:-0}" CLEAN_CACHE="${CLEAN_CACHE:-0}" DRY_RUN="${DRY_RUN:-0}" # ============================================================================= # Colors and Logging # ============================================================================= if [[ -t 1 ]]; then G='\033[0;32m' Y='\033[1;33m' R='\033[0;31m' B='\033[0;34m' C='\033[0;36m' M='\033[0;35m' N='\033[0m' else G='' Y='' R='' B='' C='' M='' N='' fi log() { echo -e "${G}[BUILD]${N} $*"; } info() { echo -e "${B}[INFO]${N} $*"; } warn() { echo -e "${Y}[WARN]${N} $*"; } fail() { echo -e "${R}[ERROR]${N} $*"; exit 1; } step() { echo -e "${C}[STEP]${N} $*"; } debug() { [[ "${DEBUG:-0}" -eq 1 ]] && echo -e "${M}[DEBUG]${N} $*" || true; } # ============================================================================= # Usage # ============================================================================= usage() { cat < Version tag for image (default: YYYYMMDD-gitsha) --output Output directory (default: project root) --push Push to registry after build --no-save Build only, no tar files --no-compress Save as .tar instead of .tar.gz --versioned Name files with version tag (default: .latest.tar.gz) --sequential Build one at a time (default: parallel) --platform

Target platform (default: linux/amd64) --buildx Use Docker Buildx for builds (better caching) --clean-cache Clean Docker build cache before building --dry-run Show what would be done without executing --ci CI mode: plain progress output, no colors --debug Enable debug output -h, --help Show this help Platform Options: linux/amd64 Standard x86_64 servers (default) linux/arm64 ARM64 servers (Apple Silicon, Graviton) Examples: $0 # Output: portal-frontend.latest.tar.gz $0 --versioned # Output: portal-frontend.20251201-abc123.tar.gz $0 --tag v1.2.3 --versioned # Output: portal-frontend.v1.2.3.tar.gz $0 --sequential --no-save # Debug build $0 --platform linux/arm64 # Build for ARM64 $0 --buildx --clean-cache # Fresh buildx build $0 --ci # CI-friendly output Environment Variables: IMAGE_FRONTEND_NAME Override frontend image name (default: portal-frontend) IMAGE_BACKEND_NAME Override backend image name (default: portal-backend) PNPM_VERSION Override PNPM version (default: from package.json) NEXT_PUBLIC_API_BASE Next.js API base path (default: /api) DEBUG=1 Enable debug output EOF exit 0 } # ============================================================================= # Argument Parsing # ============================================================================= while [[ $# -gt 0 ]]; do case "$1" in --tag) IMAGE_TAG="${2:-}"; shift 2 ;; --output) OUTPUT_DIR="${2:-}"; shift 2 ;; --push) PUSH_REMOTE="${2:-}"; shift 2 ;; --no-save) SAVE_TARS=0; shift ;; --no-compress) COMPRESS=0; shift ;; --versioned) USE_LATEST_FILENAME=0; shift ;; --sequential) PARALLEL=0; shift ;; --platform) PLATFORM="${2:-linux/amd64}"; shift 2 ;; --buildx) USE_BUILDX=1; shift ;; --clean-cache) CLEAN_CACHE=1; shift ;; --dry-run) DRY_RUN=1; shift ;; --ci) PROGRESS="plain"; G=''; Y=''; R=''; B=''; C=''; M=''; N=''; shift ;; --debug) DEBUG=1; shift ;; -h|--help) usage ;; *) fail "Unknown option: $1" ;; esac done # ============================================================================= # Validation # ============================================================================= command -v docker >/dev/null 2>&1 || fail "Docker is required but not installed" cd "$PROJECT_ROOT" [[ -f apps/portal/Dockerfile ]] || fail "Missing apps/portal/Dockerfile" [[ -f apps/bff/Dockerfile ]] || fail "Missing apps/bff/Dockerfile" [[ -f package.json ]] || fail "Missing package.json" # Verify Docker daemon is running docker info >/dev/null 2>&1 || fail "Docker daemon is not running" # ============================================================================= # Setup # ============================================================================= # Auto-generate tag if not provided if [[ -z "$IMAGE_TAG" ]]; then GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo 'local') IMAGE_TAG="$(date +%Y%m%d)-${GIT_SHA}" fi # Enable BuildKit export DOCKER_BUILDKIT=1 # Extract PNPM version from package.json (packageManager field) PNPM_VERSION_FROM_PKG=$(grep -oP '"packageManager":\s*"pnpm@\K[0-9.]+' package.json 2>/dev/null || echo "") PNPM_VERSION="${PNPM_VERSION:-${PNPM_VERSION_FROM_PKG:-10.25.0}}" # Build args NEXT_PUBLIC_API_BASE="${NEXT_PUBLIC_API_BASE:-/api}" NEXT_PUBLIC_APP_NAME="${NEXT_PUBLIC_APP_NAME:-Customer Portal}" GIT_SOURCE="$(git config --get remote.origin.url 2>/dev/null || echo unknown)" GIT_COMMIT="$(git rev-parse HEAD 2>/dev/null || echo unknown)" BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # Log directory LOG_DIR="${OUTPUT_DIR}/.build-logs" mkdir -p "$LOG_DIR" # ============================================================================= # Buildx Setup # ============================================================================= BUILDER_NAME="portal-builder" setup_buildx() { if [[ "$USE_BUILDX" -eq 1 ]]; then step "Setting up Docker Buildx..." # Check if buildx is available if ! docker buildx version >/dev/null 2>&1; then warn "Docker Buildx not available, falling back to standard build" USE_BUILDX=0 return fi # Create or use existing builder if ! docker buildx inspect "$BUILDER_NAME" >/dev/null 2>&1; then docker buildx create --name "$BUILDER_NAME" --driver docker-container --bootstrap info "Created buildx builder: $BUILDER_NAME" else docker buildx use "$BUILDER_NAME" debug "Using existing buildx builder: $BUILDER_NAME" fi fi } # ============================================================================= # Clean Cache # ============================================================================= clean_cache() { if [[ "$CLEAN_CACHE" -eq 1 ]]; then step "Cleaning Docker build cache..." docker builder prune -f --filter type=exec.cachemount 2>/dev/null || true docker builder prune -f --filter unused-for=24h 2>/dev/null || true log "✅ Build cache cleaned" fi } # ============================================================================= # Build Functions # ============================================================================= # Build functions moved to build_frontend and build_backend for better argument handling build_frontend() { local logfile="$LOG_DIR/frontend.log" step "Building frontend image..." if [[ "$DRY_RUN" -eq 1 ]]; then info "[DRY-RUN] Would build frontend" return 0 fi local exit_code=0 docker build \ --load \ -f apps/portal/Dockerfile \ --platform "${PLATFORM}" \ --progress "${PROGRESS}" \ --build-arg "PNPM_VERSION=${PNPM_VERSION}" \ --build-arg "NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE}" \ --build-arg "NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}" \ --build-arg "NEXT_PUBLIC_APP_VERSION=${IMAGE_TAG}" \ -t "${IMAGE_FRONTEND}:latest" \ -t "${IMAGE_FRONTEND}:${IMAGE_TAG}" \ --label "org.opencontainers.image.version=${IMAGE_TAG}" \ --label "org.opencontainers.image.source=${GIT_SOURCE}" \ --label "org.opencontainers.image.revision=${GIT_COMMIT}" \ --label "org.opencontainers.image.created=${BUILD_DATE}" \ . > "$logfile" 2>&1 || exit_code=$? if [[ $exit_code -eq 0 ]]; then local size size=$(docker image inspect "${IMAGE_FRONTEND}:latest" --format='{{.Size}}' 2>/dev/null | numfmt --to=iec 2>/dev/null || echo "?") log "✅ Frontend built (${size})" return 0 else warn "❌ Frontend FAILED - see $logfile" tail -50 "$logfile" || true return 1 fi } build_backend() { local logfile="$LOG_DIR/backend.log" step "Building backend image..." if [[ "$DRY_RUN" -eq 1 ]]; then info "[DRY-RUN] Would build backend" return 0 fi local exit_code=0 docker build \ --load \ -f apps/bff/Dockerfile \ --platform "${PLATFORM}" \ --progress "${PROGRESS}" \ --build-arg "PNPM_VERSION=${PNPM_VERSION}" \ -t "${IMAGE_BACKEND}:latest" \ -t "${IMAGE_BACKEND}:${IMAGE_TAG}" \ --label "org.opencontainers.image.version=${IMAGE_TAG}" \ --label "org.opencontainers.image.source=${GIT_SOURCE}" \ --label "org.opencontainers.image.revision=${GIT_COMMIT}" \ --label "org.opencontainers.image.created=${BUILD_DATE}" \ . > "$logfile" 2>&1 || exit_code=$? if [[ $exit_code -eq 0 ]]; then local size size=$(docker image inspect "${IMAGE_BACKEND}:latest" --format='{{.Size}}' 2>/dev/null | numfmt --to=iec 2>/dev/null || echo "?") log "✅ Backend built (${size})" return 0 else warn "❌ Backend FAILED - see $logfile" tail -50 "$logfile" || true return 1 fi } # ============================================================================= # Save Tarballs # ============================================================================= save_tarballs() { if [[ "$SAVE_TARS" -eq 0 ]] || [[ "$DRY_RUN" -eq 1 ]]; then return 0 fi mkdir -p "$OUTPUT_DIR" local save_start save_start=$(date +%s) # Determine filename suffix local file_tag if [[ "$USE_LATEST_FILENAME" -eq 1 ]]; then file_tag="latest" else file_tag="$IMAGE_TAG" fi local fe_tar be_tar if [[ "$COMPRESS" -eq 1 ]]; then # Pick fastest available compressor: pigz (parallel) > gzip local compressor comp_name if command -v pigz >/dev/null 2>&1; then compressor="pigz -p $(nproc)" comp_name="pigz" else compressor="gzip -1" comp_name="gzip" fi fe_tar="$OUTPUT_DIR/${IMAGE_FRONTEND}.${file_tag}.tar.gz" be_tar="$OUTPUT_DIR/${IMAGE_BACKEND}.${file_tag}.tar.gz" log "💾 Compressing with $comp_name..." (docker save "${IMAGE_FRONTEND}:latest" | $compressor > "$fe_tar") & (docker save "${IMAGE_BACKEND}:latest" | $compressor > "$be_tar") & wait else fe_tar="$OUTPUT_DIR/${IMAGE_FRONTEND}.${file_tag}.tar" be_tar="$OUTPUT_DIR/${IMAGE_BACKEND}.${file_tag}.tar" log "💾 Saving uncompressed tarballs..." docker save -o "$fe_tar" "${IMAGE_FRONTEND}:latest" & docker save -o "$be_tar" "${IMAGE_BACKEND}:latest" & wait fi local save_time save_time=$(($(date +%s) - save_start)) # Generate checksums sha256sum "$fe_tar" > "${fe_tar}.sha256" sha256sum "$be_tar" > "${be_tar}.sha256" log "✅ Saved in ${save_time}s:" printf " %-50s %s\n" "$fe_tar" "$(du -h "$fe_tar" | cut -f1)" printf " %-50s %s\n" "$be_tar" "$(du -h "$be_tar" | cut -f1)" } # ============================================================================= # Push to Registry # ============================================================================= push_images() { if [[ -z "$PUSH_REMOTE" ]] || [[ "$DRY_RUN" -eq 1 ]]; then return 0 fi log "📤 Pushing to ${PUSH_REMOTE}..." for img in "${IMAGE_FRONTEND}" "${IMAGE_BACKEND}"; do for tag in "latest" "${IMAGE_TAG}"; do docker tag "${img}:${tag}" "${PUSH_REMOTE}/${img}:${tag}" docker push "${PUSH_REMOTE}/${img}:${tag}" & done done wait log "✅ Pushed to registry" } # ============================================================================= # Main Execution # ============================================================================= main() { local start_time start_time=$(date +%s) echo "" log "🐳 Customer Portal Docker Build" log "================================" info "🏷️ Tag: ${IMAGE_TAG}" info "📦 PNPM: ${PNPM_VERSION} | Platform: ${PLATFORM}" info "📁 Build logs: $LOG_DIR/" [[ "$DRY_RUN" -eq 1 ]] && warn "🔍 DRY-RUN MODE - no actual builds" echo "" # Setup setup_buildx clean_cache # Build images log "🚀 Starting Docker builds..." if [[ "$PARALLEL" -eq 1 ]]; then log "Building frontend & backend in parallel..." build_frontend & FE_PID=$! build_backend & BE_PID=$! # Track progress local elapsed=0 while kill -0 $FE_PID 2>/dev/null || kill -0 $BE_PID 2>/dev/null; do sleep 10 elapsed=$((elapsed + 10)) info "⏳ Building... (${elapsed}s elapsed)" done # Check results local fe_exit=0 be_exit=0 wait $FE_PID || fe_exit=$? wait $BE_PID || be_exit=$? [[ $fe_exit -ne 0 ]] && fail "Frontend build failed (exit $fe_exit) - check $LOG_DIR/frontend.log" [[ $be_exit -ne 0 ]] && fail "Backend build failed (exit $be_exit) - check $LOG_DIR/backend.log" else log "🔧 Sequential build mode..." build_frontend || fail "Frontend build failed - check $LOG_DIR/frontend.log" build_backend || fail "Backend build failed - check $LOG_DIR/backend.log" fi local build_time build_time=$(($(date +%s) - start_time)) log "⏱️ Build completed in ${build_time}s" # Save and push save_tarballs push_images # Summary local total_time total_time=$(($(date +%s) - start_time)) echo "" log "🎉 Complete in ${total_time}s" echo "" # Show next steps if [[ "$SAVE_TARS" -eq 1 ]] && [[ "$DRY_RUN" -eq 0 ]]; then local file_tag [[ "$USE_LATEST_FILENAME" -eq 1 ]] && file_tag="latest" || file_tag="$IMAGE_TAG" info "📋 Next steps for Plesk deployment:" echo "" echo " 1. Upload tarballs to your server:" echo " scp ${IMAGE_FRONTEND}.${file_tag}.tar.gz* ${IMAGE_BACKEND}.${file_tag}.tar.gz* user@server:/path/" echo "" echo " 2. Load images on the server:" if [[ "$COMPRESS" -eq 1 ]]; then echo " gunzip -c ${IMAGE_FRONTEND}.${file_tag}.tar.gz | docker load" echo " gunzip -c ${IMAGE_BACKEND}.${file_tag}.tar.gz | docker load" else echo " docker load -i ${IMAGE_FRONTEND}.${file_tag}.tar" echo " docker load -i ${IMAGE_BACKEND}.${file_tag}.tar" fi echo "" echo " 3. Verify checksums:" echo " sha256sum -c ${IMAGE_FRONTEND}.${file_tag}.tar.gz.sha256" echo " sha256sum -c ${IMAGE_BACKEND}.${file_tag}.tar.gz.sha256" echo "" if [[ "$USE_LATEST_FILENAME" -eq 0 ]]; then echo " 4. Update Portainer with tag: ${IMAGE_TAG}" echo "" fi fi } # Run main main