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>
300 lines
15 KiB
Bash
Executable file
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
|