#!/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 ───────────────────────────────────────────────────────────── API_PORT=${API_PORT:-8000} WEB_PORT=${WEB_PORT:-3000} PG_PORT=${PG_PORT:-5432} REDIS_PORT=${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..." # Returns the PID+process using a port, empty if free or used by our containers port_owner() { local port=$1 # ss -tlnp output: "users:(("process",pid=NNN,...))" ss -tlnp "sport = :${port}" 2>/dev/null | awk 'NR>1 {print $NF}' | grep -oP '"[^"]+",pid=\K[0-9]+' | head -1 } check_port() { local port=$1 local varname=$2 local service=$3 local pid pid=$(port_owner "$port" || true) if [[ -z "$pid" ]]; then info " Port $port ($service): free" return fi # Check if it belongs to OUR docker-compose project (not other apps on server) 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 info " Port $port ($service): used by our container (will be replaced on restart)" return fi fi warn " Port $port ($service) is in use by PID $pid ($proc_name)" read -rp " Enter alternative port for $service [$port]: " new_port new_port="${new_port:-$port}" eval "$varname=$new_port" info " Using port $new_port for $service" } 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.solution" .env; then warn "ALLOWED_ORIGINS may not include https://optical-dev.oliver.solution — 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..." APACHE_CONF_SRC="$SCRIPT_DIR/apache/deckforge.conf" APACHE_CONF_DST="/etc/apache2/sites-available/deckforge.conf" # Substitute actual ports into the template sed \ -e "s/API_PORT/${API_PORT}/g" \ -e "s/WEB_PORT/${WEB_PORT}/g" \ "$APACHE_CONF_SRC" | sudo tee "$APACHE_CONF_DST" > /dev/null info " Written $APACHE_CONF_DST" # Disable default site on first install if [[ -L /etc/apache2/sites-enabled/000-default.conf ]]; then sudo a2dissite 000-default.conf -q || true info " Disabled 000-default.conf" fi sudo a2ensite deckforge.conf -q info " Enabled deckforge.conf" # 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 — check $APACHE_CONF_DST" 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 if curl -sf "http://127.0.0.1:${API_PORT}/docs" > /dev/null; then info " API (port ${API_PORT}): OK" else warn " API (port ${API_PORT}): not responding — check 'docker compose logs api'" FAIL=1 fi if curl -sf "http://127.0.0.1:${WEB_PORT}/ppt-tool/" > /dev/null; 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.solution/ppt-tool/" else warn "Deploy finished with warnings. Review above messages." fi