#!/usr/bin/env bash # DeckForge Production Deploy Script # Idempotent — safe to run multiple times # Run as normal user; script uses sudo internally for privileged operations set -euo pipefail # ── Colours ────────────────────────────────────────────────────────────────── RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; NC='\033[0m' info() { echo -e "${GREEN}[deploy]${NC} $*"; } warn() { echo -e "${YELLOW}[warn]${NC} $*"; } error() { echo -e "${RED}[error]${NC} $*" >&2; } # ── Sudo check (need it for apt/apache/ufw but NOT for git/docker) ──────────── if ! sudo -n true 2>/dev/null; then warn "This script needs sudo for apt/apache/ufw — you may be prompted for your password." fi SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # ── Default ports — read from .env first, then env, then hardcoded default ──── env_val() { local key=$1 def=$2; grep -E "^${key}=" .env 2>/dev/null | cut -d= -f2 | tr -d '"' | head -1 | grep -v '^$' || echo "$def"; } API_PORT=${API_PORT:-$(env_val API_PORT 8000)} WEB_PORT=${WEB_PORT:-$(env_val WEB_PORT 3000)} PG_PORT=${PG_PORT:-$(env_val PG_PORT 5432)} REDIS_PORT=${REDIS_PORT:-$(env_val REDIS_PORT 6379)} # ───────────────────────────────────────────────────────────────────────────── # STEP 1: Prerequisites — install missing packages # ───────────────────────────────────────────────────────────────────────────── info "Step 1: Checking prerequisites..." install_if_missing() { local cmd=$1; local pkg=${2:-$1} if ! command -v "$cmd" &>/dev/null; then info " Installing $pkg..." sudo apt-get install -y "$pkg" -qq else info " $cmd: OK" fi } sudo apt-get update -qq install_if_missing docker docker.io install_if_missing git git install_if_missing curl curl install_if_missing ss iproute2 install_if_missing ufw ufw # Docker Compose plugin if ! docker compose version &>/dev/null 2>&1; then info " Installing docker-compose-plugin..." sudo apt-get install -y docker-compose-plugin -qq fi # Apache2 if ! command -v apache2 &>/dev/null; then info " Installing apache2..." sudo apt-get install -y apache2 -qq fi # Enable required Apache modules info " Enabling Apache modules..." sudo a2enmod proxy proxy_http proxy_wstunnel headers rewrite -q # ───────────────────────────────────────────────────────────────────────────── # STEP 1.5: Port conflict check # ───────────────────────────────────────────────────────────────────────────── info "Step 1.5: Checking port availability..." # Check if a port is in use by a NON-deckforge process port_taken_by_other() { local port=$1 # Check if anything is listening on this port ss -tlnp 2>/dev/null | grep -qw ":${port}" || return 1 # Check if it belongs to OUR docker-compose project local our_ids our_ids=$(docker compose -f docker-compose.yml -f docker-compose.prod.yml ps -q 2>/dev/null || true) if [[ -n "$our_ids" ]]; then # shellcheck disable=SC2086 if docker inspect $our_ids 2>/dev/null | grep -q "\"HostPort\": \"${port}\""; then return 1 # It's ours — will be replaced on restart fi fi return 0 # Taken by someone else } # Find next free port starting from given port find_free_port() { local port=$1 while port_taken_by_other "$port"; do port=$((port + 1)) done echo "$port" } # Write or update a key in .env set_env_port() { local key=$1 val=$2 if grep -qE "^${key}=" .env 2>/dev/null; then sed -i "s|^${key}=.*|${key}=${val}|" .env else echo "${key}=${val}" >> .env fi } check_port() { local port=$1 local varname=$2 local service=$3 if ! port_taken_by_other "$port"; then info " Port $port ($service): OK" return fi local new_port new_port=$(find_free_port "$((port + 1))") warn " Port $port ($service) is taken by another process — using $new_port instead" eval "$varname=$new_port" set_env_port "$varname" "$new_port" info " Saved ${varname}=${new_port} to .env" } check_port "$API_PORT" API_PORT "api" check_port "$WEB_PORT" WEB_PORT "web" check_port "$PG_PORT" PG_PORT "postgres" check_port "$REDIS_PORT" REDIS_PORT "redis" export API_PORT WEB_PORT PG_PORT REDIS_PORT # ───────────────────────────────────────────────────────────────────────────── # STEP 2: Pull latest code # ───────────────────────────────────────────────────────────────────────────── info "Step 2: Pulling latest code..." git pull origin main # ───────────────────────────────────────────────────────────────────────────── # STEP 3: Environment file # ───────────────────────────────────────────────────────────────────────────── info "Step 3: Checking .env..." if [[ ! -f .env ]]; then if [[ -f .env.example ]]; then cp .env.example .env warn ".env was missing — copied from .env.example" warn "Edit .env with real secrets before continuing (Ctrl-C to abort)" read -rp "Press Enter to continue or Ctrl-C to abort..." else error ".env and .env.example both missing. Cannot continue." exit 1 fi fi # Ensure ALLOWED_ORIGINS contains the production domain if ! grep -q "optical-dev.oliver.solutions" .env; then warn "ALLOWED_ORIGINS may not include https://optical-dev.oliver.solutions — please verify" fi # Warn on default secrets check_default() { local key=$1; local default=$2 local val val=$(grep -E "^${key}=" .env | cut -d= -f2- | tr -d '"' || true) if [[ "$val" == "$default" || -z "$val" ]]; then warn "${key} appears to be unset or default ('${default}') — change it!" fi } check_default JWT_SECRET_KEY "changeme" check_default DEV_AUTH_PASSWORD "changeme" # ───────────────────────────────────────────────────────────────────────────── # STEP 4: Patch next.config.mjs with basePath (build-time config) # ───────────────────────────────────────────────────────────────────────────── info "Step 4: Patching frontend/next.config.mjs with basePath..." NEXT_CONFIG="frontend/next.config.mjs" if grep -q 'basePath' "$NEXT_CONFIG"; then info " basePath already present — skipping" else # Insert basePath + assetPrefix after "const nextConfig = {" sed -i 's/const nextConfig = {/const nextConfig = {\n basePath: "\/ppt-tool",\n assetPrefix: "\/ppt-tool",/' "$NEXT_CONFIG" info " Injected basePath and assetPrefix into $NEXT_CONFIG" fi # ───────────────────────────────────────────────────────────────────────────── # STEP 5: Generate docker-compose.prod.yml with resolved ports # ───────────────────────────────────────────────────────────────────────────── info "Step 5: Writing docker-compose.prod.yml..." # The file is already in the repo; just export port vars so Docker Compose # variable substitution picks them up (already done via export above). # Re-state for clarity: info " API_PORT=$API_PORT WEB_PORT=$WEB_PORT PG_PORT=$PG_PORT REDIS_PORT=$REDIS_PORT" # ───────────────────────────────────────────────────────────────────────────── # STEP 6: Build Docker images # ───────────────────────────────────────────────────────────────────────────── info "Step 6: Building Docker images..." docker compose -f docker-compose.yml -f docker-compose.prod.yml build # ───────────────────────────────────────────────────────────────────────────── # STEP 7: Start services (except nginx — Apache replaces it) # ───────────────────────────────────────────────────────────────────────────── info "Step 7: Starting services..." docker compose -f docker-compose.yml -f docker-compose.prod.yml \ up -d postgres redis api worker web # ───────────────────────────────────────────────────────────────────────────── # STEP 8: Wait for Postgres to be ready # ───────────────────────────────────────────────────────────────────────────── info "Step 8: Waiting for Postgres..." MAX_WAIT=30 for i in $(seq 1 $MAX_WAIT); do if docker compose -f docker-compose.yml -f docker-compose.prod.yml \ exec -T postgres pg_isready -U deckforge &>/dev/null; then info " Postgres ready (${i}s)" break fi if [[ $i -eq $MAX_WAIT ]]; then error "Postgres did not become ready within ${MAX_WAIT}s" exit 1 fi sleep 1 done # ───────────────────────────────────────────────────────────────────────────── # STEP 9: Database migrations + seed # ───────────────────────────────────────────────────────────────────────────── info "Step 9: Running Alembic migrations..." docker compose -f docker-compose.yml -f docker-compose.prod.yml \ exec -T api alembic upgrade head info " Running seed script (idempotent)..." docker compose -f docker-compose.yml -f docker-compose.prod.yml \ exec -T api python -m scripts.seed || warn "Seed script failed or already seeded — continuing" # ───────────────────────────────────────────────────────────────────────────── # STEP 10: Apache configuration # ───────────────────────────────────────────────────────────────────────────── info "Step 10: Configuring Apache..." # DeckForge rules are merged into optical-dev.oliver.solutions.conf (managed by OliVAS team). # We only need to ensure deckforge.conf is disabled (if it still exists from a prior deploy) # and reload Apache. if [[ -L /etc/apache2/sites-enabled/deckforge.conf ]]; then sudo a2dissite deckforge.conf -q || true info " Disabled legacy deckforge.conf" fi # Disable default site if present if [[ -L /etc/apache2/sites-enabled/000-default.conf ]]; then sudo a2dissite 000-default.conf -q || true info " Disabled 000-default.conf" fi # Health check page — Google LB requires GET / to return 200 sudo mkdir -p /var/www/html sudo tee /var/www/html/index.html > /dev/null << 'EOF' DeckForge EOF info " Health check page written to /var/www/html/index.html" # Test Apache config before reloading if sudo apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then sudo systemctl reload apache2 info " Apache reloaded" else error "Apache config test failed" sudo apache2ctl configtest exit 1 fi # ───────────────────────────────────────────────────────────────────────────── # STEP 11: UFW Firewall # ───────────────────────────────────────────────────────────────────────────── info "Step 11: Configuring UFW firewall..." sudo ufw default deny incoming -y 2>/dev/null || true sudo ufw default allow outgoing -y 2>/dev/null || true sudo ufw allow 22/tcp sudo ufw allow 80/tcp sudo ufw --force enable info " UFW enabled (22/tcp, 80/tcp allowed)" # ───────────────────────────────────────────────────────────────────────────── # STEP 12: Verification # ───────────────────────────────────────────────────────────────────────────── info "Step 12: Verifying deployment..." FAIL=0 info " Waiting for API to be ready..." API_OK=0 for i in $(seq 1 24); do if curl -sf "http://127.0.0.1:${API_PORT}/docs" > /dev/null 2>&1; then API_OK=1; break fi sleep 5 done if [ "$API_OK" -eq 1 ]; then info " API (port ${API_PORT}): OK" else warn " API (port ${API_PORT}): not responding — check 'docker compose logs api'" FAIL=1 fi info " Waiting for frontend to be ready (Next.js takes ~30-60s to start)..." WEB_OK=0 for i in $(seq 1 24); do if curl -sf "http://127.0.0.1:${WEB_PORT}/ppt-tool/" > /dev/null 2>&1; then WEB_OK=1; break fi sleep 5 done if [ "$WEB_OK" -eq 1 ]; then info " Frontend (port ${WEB_PORT}): OK" else warn " Frontend (port ${WEB_PORT}): not responding — check 'docker compose logs web'" FAIL=1 fi echo "" docker compose -f docker-compose.yml -f docker-compose.prod.yml ps echo "" if [[ $FAIL -eq 0 ]]; then info "Deploy complete. Visit https://optical-dev.oliver.solutions/ppt-tool/" else warn "Deploy finished with warnings. Review above messages." fi