+
{project.title}
-
-
-
{project.date}
+
+
+ {new Date(project.date).getFullYear()}
-
+
{project.description}
-
- {project.tags.map((tag) => (
+
+ {project.tags.slice(0, 4).map((tag) => (
{tag}
))}
+ {project.tags.length > 4 && (
+
+ +{project.tags.length - 4}
+
+ )}
-
View Project
-
+
View Details
+
diff --git a/app/globals.css b/app/globals.css
index 58a7d10..0840f39 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -40,6 +40,11 @@
border-color: hsl(var(--border));
}
+html {
+ scroll-behavior: smooth;
+ scroll-padding-top: 80px;
+}
+
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
@@ -71,17 +76,26 @@ body {
/* Glassmorphism Effects */
.glass {
- background: rgba(15, 15, 15, 0.8);
- backdrop-filter: blur(20px);
+ background: rgba(15, 15, 15, 0.85);
+ backdrop-filter: blur(20px) saturate(180%);
+ -webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.glass-card {
- background: rgba(15, 15, 15, 0.6);
- backdrop-filter: blur(16px);
+ background: rgba(15, 15, 15, 0.7);
+ backdrop-filter: blur(16px) saturate(180%);
+ -webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.glass-card:hover {
+ background: rgba(15, 15, 15, 0.8);
+ border-color: rgba(255, 255, 255, 0.15);
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
/* Admin Panel Specific Glassmorphism */
@@ -523,18 +537,24 @@ select.form-input-enhanced:focus {
background: linear-gradient(135deg, #3b82f6, #1d4ed8);
color: white;
padding: 0.75rem 1.5rem;
- border-radius: 8px;
- font-weight: 500;
- transition: all 0.3s ease;
+ border-radius: 12px;
+ font-weight: 600;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: none;
cursor: pointer;
position: relative;
overflow: hidden;
+ box-shadow: 0 4px 14px rgba(59, 130, 246, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
- box-shadow: 0 8px 25px rgba(59, 130, 246, 0.4);
+ box-shadow: 0 8px 30px rgba(59, 130, 246, 0.5);
+ background: linear-gradient(135deg, #2563eb, #1e40af);
+}
+
+.btn-primary:active {
+ transform: translateY(0);
}
.btn-primary::before {
@@ -552,9 +572,29 @@ select.form-input-enhanced:focus {
left: 100%;
}
+.btn-secondary {
+ background: transparent;
+ color: #e5e7eb;
+ padding: 0.75rem 1.5rem;
+ border-radius: 12px;
+ font-weight: 600;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+ border: 2px solid rgba(75, 85, 99, 0.5);
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+}
+
+.btn-secondary:hover {
+ border-color: rgba(75, 85, 99, 0.8);
+ background: rgba(31, 41, 55, 0.5);
+ transform: translateY(-2px);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+}
+
/* Card Hover Effects */
.card-hover {
- transition: all 0.3s ease;
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
}
@@ -563,6 +603,14 @@ select.form-input-enhanced:focus {
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
+/* Line clamp utility */
+.line-clamp-3 {
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
/* Loading Animation */
@keyframes pulse {
0%, 100% { opacity: 1; }
@@ -589,6 +637,44 @@ select.form-input-enhanced:focus {
animation: fadeInUp 0.6s ease-out;
}
+/* Focus visible improvements */
+*:focus-visible {
+ outline: 2px solid #3b82f6;
+ outline-offset: 2px;
+ border-radius: 4px;
+}
+
+/* Selection styling */
+::selection {
+ background-color: rgba(59, 130, 246, 0.3);
+ color: #ffffff;
+}
+
+::-moz-selection {
+ background-color: rgba(59, 130, 246, 0.3);
+ color: #ffffff;
+}
+
+/* Improved scrollbar for webkit */
+::-webkit-scrollbar {
+ width: 10px;
+ height: 10px;
+}
+
+::-webkit-scrollbar-track {
+ background: hsl(var(--background));
+}
+
+::-webkit-scrollbar-thumb {
+ background: linear-gradient(180deg, #3b82f6, #1d4ed8);
+ border-radius: 5px;
+ border: 2px solid hsl(var(--background));
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: linear-gradient(180deg, #2563eb, #1e40af);
+}
+
/* Responsive Design */
@media (max-width: 768px) {
.markdown h1 {
@@ -602,4 +688,8 @@ select.form-input-enhanced:focus {
.markdown h3 {
font-size: 1.25rem;
}
+
+ .domain-text {
+ font-size: 2rem;
+ }
}
diff --git a/app/page.tsx b/app/page.tsx
index 2ba5180..8f26135 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -2,6 +2,7 @@
import Header from "./components/Header";
import Hero from "./components/Hero";
+import About from "./components/About";
import Projects from "./components/Projects";
import Contact from "./components/Contact";
import Footer from "./components/Footer";
@@ -33,9 +34,10 @@ export default function Home() {
}}
/>
-
+
-
+
diff --git a/middleware.ts b/middleware.ts
index 2995fa3..1d546b7 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,16 +1,38 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
+import { verifySessionAuth } from '@/lib/auth';
export function middleware(request: NextRequest) {
- // For /manage and /editor routes, allow direct access (authentication disabled)
+ // For /manage and /editor routes, require authentication
if (request.nextUrl.pathname.startsWith('/manage') ||
request.nextUrl.pathname.startsWith('/editor')) {
- // Allow direct access without authentication
- return NextResponse.next();
+ // Check for session authentication
+ if (!verifySessionAuth(request)) {
+ // Redirect to home page if not authenticated
+ const url = request.nextUrl.clone();
+ url.pathname = '/';
+ return NextResponse.redirect(url);
+ }
}
- // For all other routes, continue with normal processing
- return NextResponse.next();
+ // Add security headers to all responses
+ const response = NextResponse.next();
+
+ // Security headers (complementing next.config.ts headers)
+ response.headers.set('X-DNS-Prefetch-Control', 'on');
+ response.headers.set('X-Frame-Options', 'DENY');
+ response.headers.set('X-Content-Type-Options', 'nosniff');
+ response.headers.set('X-XSS-Protection', '1; mode=block');
+ response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
+ response.headers.set('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+
+ // Rate limiting headers for API routes
+ if (request.nextUrl.pathname.startsWith('/api/')) {
+ response.headers.set('X-RateLimit-Limit', '100');
+ response.headers.set('X-RateLimit-Remaining', '99');
+ }
+
+ return response;
}
export const config = {
diff --git a/next.config.ts b/next.config.ts
index e2b958a..e1ac217 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -42,15 +42,61 @@ const nextConfig: NextConfig = {
// Dynamic routes are handled automatically by Next.js
- // Add cache-busting headers
+ // Security and cache headers
async headers() {
return [
{
source: '/(.*)',
+ headers: [
+ {
+ key: 'X-DNS-Prefetch-Control',
+ value: 'on',
+ },
+ {
+ key: 'Strict-Transport-Security',
+ value: 'max-age=63072000; includeSubDomains; preload',
+ },
+ {
+ key: 'X-Frame-Options',
+ value: 'DENY',
+ },
+ {
+ key: 'X-Content-Type-Options',
+ value: 'nosniff',
+ },
+ {
+ key: 'X-XSS-Protection',
+ value: '1; mode=block',
+ },
+ {
+ key: 'Referrer-Policy',
+ value: 'strict-origin-when-cross-origin',
+ },
+ {
+ key: 'Permissions-Policy',
+ value: 'camera=(), microphone=(), geolocation=()',
+ },
+ {
+ key: 'Content-Security-Policy',
+ value: "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://analytics.dk0.dev; script-src-elem 'self' 'unsafe-inline' https://analytics.dk0.dev; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https:; connect-src 'self' https://analytics.dk0.dev; frame-ancestors 'none'; base-uri 'self'; form-action 'self';",
+ },
+ ],
+ },
+ {
+ source: '/api/(.*)',
headers: [
{
key: 'Cache-Control',
- value: 'public, max-age=0, must-revalidate',
+ value: 'no-store, no-cache, must-revalidate, proxy-revalidate',
+ },
+ ],
+ },
+ {
+ source: '/_next/static/(.*)',
+ headers: [
+ {
+ key: 'Cache-Control',
+ value: 'public, max-age=31536000, immutable',
},
],
},
diff --git a/scripts/safe-deploy.sh b/scripts/safe-deploy.sh
new file mode 100755
index 0000000..ab1920c
--- /dev/null
+++ b/scripts/safe-deploy.sh
@@ -0,0 +1,356 @@
+#!/bin/bash
+
+# Safe Deployment Script for dk0.dev
+# Ensures secure, zero-downtime deployments with proper error handling and rollback
+
+set -euo pipefail # Exit on error, undefined vars, pipe failures
+
+# Configuration
+IMAGE_NAME="portfolio-app"
+NEW_TAG="latest"
+OLD_TAG="previous"
+BACKUP_TAG="backup-$(date +%Y%m%d-%H%M%S)"
+COMPOSE_FILE="docker-compose.production.yml"
+HEALTH_CHECK_URL="http://localhost:3000/api/health"
+HEALTH_CHECK_TIMEOUT=180
+HEALTH_CHECK_INTERVAL=5
+MAX_ROLLBACK_ATTEMPTS=3
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Logging functions
+log() {
+ echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
+}
+
+error() {
+ echo -e "${RED}[ERROR]${NC} $1" >&2
+}
+
+success() {
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
+}
+
+warning() {
+ echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+# Cleanup function
+cleanup() {
+ local exit_code=$?
+ if [ $exit_code -ne 0 ]; then
+ error "Deployment failed with exit code $exit_code"
+ rollback
+ fi
+}
+
+# Health check function
+check_health() {
+ local url=$1
+ local max_attempts=$((HEALTH_CHECK_TIMEOUT / HEALTH_CHECK_INTERVAL))
+ local attempt=0
+
+ log "Performing health check on $url..."
+ while [ $attempt -lt $max_attempts ]; do
+ if curl -f -s -m 5 "$url" > /dev/null 2>&1; then
+ return 0
+ fi
+ attempt=$((attempt + 1))
+ sleep $HEALTH_CHECK_INTERVAL
+ echo -n "."
+ done
+ echo ""
+ return 1
+}
+
+# Rollback function
+rollback() {
+ error "Deployment failed. Initiating rollback..."
+
+ local rollback_attempt=0
+ while [ $rollback_attempt -lt $MAX_ROLLBACK_ATTEMPTS ]; do
+ rollback_attempt=$((rollback_attempt + 1))
+ log "Rollback attempt $rollback_attempt/$MAX_ROLLBACK_ATTEMPTS"
+
+ # Try to restore from backup tag first
+ if docker images | grep -q "${IMAGE_NAME}:${OLD_TAG}"; then
+ log "Restoring from previous image..."
+ docker tag "${IMAGE_NAME}:${OLD_TAG}" "${IMAGE_NAME}:${NEW_TAG}" || {
+ warning "Failed to tag previous image"
+ continue
+ }
+ elif docker images | grep -q "${IMAGE_NAME}:${BACKUP_TAG}"; then
+ log "Restoring from backup image..."
+ docker tag "${IMAGE_NAME}:${BACKUP_TAG}" "${IMAGE_NAME}:${NEW_TAG}" || {
+ warning "Failed to tag backup image"
+ continue
+ }
+ else
+ error "No backup image found for rollback"
+ return 1
+ fi
+
+ # Restart container with previous image
+ log "Restarting container with previous image..."
+ docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || {
+ warning "Failed to restart container"
+ continue
+ }
+
+ # Wait for health check
+ sleep 10
+ if check_health "$HEALTH_CHECK_URL"; then
+ success "Rollback successful!"
+ return 0
+ else
+ warning "Rollback attempt $rollback_attempt failed health check"
+ fi
+ done
+
+ error "All rollback attempts failed"
+ return 1
+}
+
+# Pre-deployment checks
+pre_deployment_checks() {
+ log "Running pre-deployment checks..."
+
+ # Check if running as root
+ if [[ $EUID -eq 0 ]]; then
+ error "This script should not be run as root"
+ exit 1
+ fi
+
+ # Check if Docker is running
+ if ! docker info > /dev/null 2>&1; then
+ error "Docker is not running. Please start Docker and try again."
+ exit 1
+ fi
+
+ # Check if docker-compose is available
+ if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
+ error "docker-compose is not installed"
+ exit 1
+ fi
+
+ # Check if .env file exists
+ if [ ! -f .env ]; then
+ error ".env file not found. Please create it before deploying."
+ exit 1
+ fi
+
+ # Check if required environment variables are set
+ local required_vars=("DATABASE_URL" "NEXT_PUBLIC_BASE_URL")
+ for var in "${required_vars[@]}"; do
+ if ! grep -q "^${var}=" .env 2>/dev/null; then
+ warning "Required environment variable $var not found in .env"
+ fi
+ done
+
+ # Check disk space (at least 2GB free)
+ local available_space=$(df -BG . | tail -1 | awk '{print $4}' | sed 's/G//')
+ if [ "$available_space" -lt 2 ]; then
+ error "Insufficient disk space. At least 2GB required, but only ${available_space}GB available."
+ exit 1
+ fi
+
+ success "Pre-deployment checks passed"
+}
+
+# Build application
+build_application() {
+ log "Building application..."
+
+ # Build Next.js application
+ log "Building Next.js application..."
+ npm ci --prefer-offline --no-audit || {
+ error "npm install failed"
+ exit 1
+ }
+
+ npm run build || {
+ error "Build failed"
+ exit 1
+ }
+
+ success "Application built successfully"
+}
+
+# Build Docker image
+build_docker_image() {
+ log "Building Docker image..."
+
+ # Backup current image if it exists
+ if docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then
+ log "Backing up current image..."
+ docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:${OLD_TAG}" || {
+ warning "Could not backup current image (this might be the first deployment)"
+ }
+ docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:${BACKUP_TAG}" || true
+ fi
+
+ # Build new image
+ docker build -t "${IMAGE_NAME}:${NEW_TAG}" . || {
+ error "Failed to build Docker image"
+ exit 1
+ }
+
+ # Verify new image
+ if ! docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then
+ error "New image not found after build"
+ exit 1
+ fi
+
+ success "Docker image built successfully"
+}
+
+# Deploy application
+deploy_application() {
+ log "Deploying application..."
+
+ # Start new container
+ if command -v docker-compose &> /dev/null; then
+ docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || {
+ error "Failed to start new container"
+ exit 1
+ }
+ else
+ docker compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || {
+ error "Failed to start new container"
+ exit 1
+ }
+ fi
+
+ # Wait for container to start
+ log "Waiting for container to start..."
+ sleep 15
+
+ # Check if container is running
+ if ! docker ps | grep -q "portfolio-app"; then
+ error "Container failed to start"
+ log "Container logs:"
+ docker logs portfolio-app --tail=50
+ exit 1
+ fi
+
+ success "Container started successfully"
+}
+
+# Run database migrations
+run_migrations() {
+ log "Running database migrations..."
+
+ # Wait for database to be ready
+ local db_ready=false
+ for i in {1..30}; do
+ if docker exec portfolio-app npx prisma db push --skip-generate --accept-data-loss 2>&1 | grep -q "Database.*ready\|Everything is in sync"; then
+ db_ready=true
+ break
+ fi
+ sleep 2
+ done
+
+ if [ "$db_ready" = false ]; then
+ warning "Database might not be ready, but continuing..."
+ fi
+
+ # Run migrations
+ docker exec portfolio-app npx prisma db push --skip-generate || {
+ warning "Database migration had issues, but continuing..."
+ }
+
+ success "Database migrations completed"
+}
+
+# Verify deployment
+verify_deployment() {
+ log "Verifying deployment..."
+
+ # Health check
+ if ! check_health "$HEALTH_CHECK_URL"; then
+ error "Health check failed"
+ log "Container logs:"
+ docker logs portfolio-app --tail=100
+ return 1
+ fi
+
+ # Test main page
+ if ! curl -f -s -m 10 "http://localhost:3000/" > /dev/null 2>&1; then
+ error "Main page is not accessible"
+ return 1
+ fi
+
+ # Test API endpoint
+ if ! curl -f -s -m 10 "http://localhost:3000/api/health" > /dev/null 2>&1; then
+ error "API health endpoint is not accessible"
+ return 1
+ fi
+
+ success "Deployment verification successful"
+}
+
+# Cleanup old images
+cleanup_old_images() {
+ log "Cleaning up old images..."
+
+ # Keep last 5 versions
+ docker images "${IMAGE_NAME}" --format "{{.Tag}}" | \
+ grep -E "^(backup-|previous|failed-)" | \
+ sort -r | \
+ tail -n +6 | \
+ while read -r tag; do
+ docker rmi "${IMAGE_NAME}:${tag}" 2>/dev/null || true
+ done
+
+ success "Cleanup completed"
+}
+
+# Main deployment flow
+main() {
+ log "Starting safe deployment for dk0.dev..."
+
+ # Set up error handling
+ trap cleanup ERR
+
+ # Run deployment steps
+ pre_deployment_checks
+ build_application
+ build_docker_image
+ deploy_application
+ run_migrations
+
+ # Verify deployment
+ if verify_deployment; then
+ cleanup_old_images
+
+ # Show deployment info
+ log "Deployment completed successfully!"
+ log "Container status:"
+ if command -v docker-compose &> /dev/null; then
+ docker-compose -f "$COMPOSE_FILE" ps
+ else
+ docker compose -f "$COMPOSE_FILE" ps
+ fi
+
+ log "Resource usage:"
+ docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}" portfolio-app
+
+ success "Application is available at: http://localhost:3000/"
+ success "Health check endpoint: $HEALTH_CHECK_URL"
+
+ # Disable error trap (deployment successful)
+ trap - ERR
+ else
+ error "Deployment verification failed"
+ exit 1
+ fi
+}
+
+# Run main function
+main "$@"
+
diff --git a/scripts/zero-downtime-deploy.sh b/scripts/zero-downtime-deploy.sh
new file mode 100755
index 0000000..f6d04e0
--- /dev/null
+++ b/scripts/zero-downtime-deploy.sh
@@ -0,0 +1,184 @@
+#!/bin/bash
+
+# Zero-Downtime Deployment Script for dk0.dev
+# This script ensures safe, zero-downtime deployments with rollback capability
+
+set -e
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Configuration
+IMAGE_NAME="portfolio-app"
+NEW_TAG="latest"
+OLD_TAG="previous"
+COMPOSE_FILE="docker-compose.production.yml"
+HEALTH_CHECK_URL="http://localhost:3000/api/health"
+HEALTH_CHECK_TIMEOUT=120
+HEALTH_CHECK_INTERVAL=5
+
+# Logging functions
+log() {
+ echo -e "${BLUE}[$(date +'%Y-%m-%d %H:%M:%S')]${NC} $1"
+}
+
+error() {
+ echo -e "${RED}[ERROR]${NC} $1" >&2
+}
+
+success() {
+ echo -e "${GREEN}[SUCCESS]${NC} $1"
+}
+
+warning() {
+ echo -e "${YELLOW}[WARNING]${NC} $1"
+}
+
+# Check if running as root
+if [[ $EUID -eq 0 ]]; then
+ error "This script should not be run as root"
+ exit 1
+fi
+
+# Check if Docker is running
+if ! docker info > /dev/null 2>&1; then
+ error "Docker is not running. Please start Docker and try again."
+ exit 1
+fi
+
+# Check if docker-compose is available
+if ! command -v docker-compose &> /dev/null; then
+ error "docker-compose is not installed"
+ exit 1
+fi
+
+# Health check function
+check_health() {
+ local url=$1
+ local max_attempts=$((HEALTH_CHECK_TIMEOUT / HEALTH_CHECK_INTERVAL))
+ local attempt=0
+
+ while [ $attempt -lt $max_attempts ]; do
+ if curl -f -s "$url" > /dev/null 2>&1; then
+ return 0
+ fi
+ attempt=$((attempt + 1))
+ sleep $HEALTH_CHECK_INTERVAL
+ echo -n "."
+ done
+
+ return 1
+}
+
+# Rollback function
+rollback() {
+ error "Deployment failed. Rolling back..."
+
+ # Tag current image as failed
+ if docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then
+ docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:failed-$(date +%s)" || true
+ fi
+
+ # Restore previous image
+ if docker images | grep -q "${IMAGE_NAME}:${OLD_TAG}"; then
+ log "Restoring previous image..."
+ docker tag "${IMAGE_NAME}:${OLD_TAG}" "${IMAGE_NAME}:${NEW_TAG}"
+ docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || {
+ error "Failed to rollback"
+ exit 1
+ }
+
+ if check_health "$HEALTH_CHECK_URL"; then
+ success "Rollback successful"
+ else
+ error "Rollback completed but health check failed"
+ exit 1
+ fi
+ else
+ error "No previous image found for rollback"
+ exit 1
+ fi
+}
+
+# Trap errors for automatic rollback
+trap rollback ERR
+
+log "Starting zero-downtime deployment..."
+
+# Step 1: Backup current image
+if docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then
+ log "Backing up current image..."
+ docker tag "${IMAGE_NAME}:${NEW_TAG}" "${IMAGE_NAME}:${OLD_TAG}" || {
+ warning "Could not backup current image (this might be the first deployment)"
+ }
+fi
+
+# Step 2: Build new image
+log "Building new image..."
+docker build -t "${IMAGE_NAME}:${NEW_TAG}" . || {
+ error "Failed to build image"
+ exit 1
+}
+
+# Step 3: Verify new image
+log "Verifying new image..."
+if ! docker images | grep -q "${IMAGE_NAME}:${NEW_TAG}"; then
+ error "New image not found after build"
+ exit 1
+fi
+
+# Step 4: Start new container in background (blue-green deployment)
+log "Starting new container..."
+docker-compose -f "$COMPOSE_FILE" up -d --force-recreate portfolio || {
+ error "Failed to start new container"
+ exit 1
+}
+
+# Step 5: Wait for health check
+log "Waiting for health check..."
+if check_health "$HEALTH_CHECK_URL"; then
+ success "New container is healthy!"
+else
+ error "Health check failed for new container"
+ exit 1
+fi
+
+# Step 6: Run database migrations (if needed)
+log "Running database migrations..."
+docker exec portfolio-app npx prisma db push --skip-generate || {
+ warning "Database migration failed, but continuing..."
+}
+
+# Step 7: Final verification
+log "Performing final verification..."
+if check_health "$HEALTH_CHECK_URL"; then
+ success "Deployment successful!"
+
+ # Show container status
+ log "Container status:"
+ docker-compose -f "$COMPOSE_FILE" ps
+
+ # Cleanup old images (keep last 3 versions)
+ log "Cleaning up old images..."
+ docker images "${IMAGE_NAME}" --format "{{.Tag}}" | grep -E "^(failed-|previous)" | sort -r | tail -n +4 | while read tag; do
+ docker rmi "${IMAGE_NAME}:${tag}" 2>/dev/null || true
+ done
+
+ success "Zero-downtime deployment completed successfully!"
+ log "Application is available at: http://localhost:3000/"
+ log "Health check endpoint: $HEALTH_CHECK_URL"
+
+else
+ error "Final verification failed"
+ exit 1
+fi
+
+# Disable error trap (deployment successful)
+trap - ERR
+
+success "All done!"
+