ppt-tool/deploy.sh
Vadym Samoilenko 1fe15a1cec Fix port check: only allow our own compose containers, not any Docker
Previously any Docker process on the port was treated as safe.
Now uses docker inspect on our project's containers specifically,
so ports used by other apps on the server trigger the conflict prompt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-19 19:25:48 +00:00

300 lines
15 KiB
Bash
Executable file

#!/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