diff --git a/apache/deckforge.conf b/apache/deckforge.conf new file mode 100644 index 0000000..b54fdab --- /dev/null +++ b/apache/deckforge.conf @@ -0,0 +1,68 @@ +# DeckForge Apache Virtual Host +# Reverse proxy to Docker services on localhost +# SSL is terminated by the upstream load balancer + + + ServerName optical-dev.oliver.solution + + # Security headers + Header always set X-Content-Type-Options "nosniff" + Header always set X-Frame-Options "SAMEORIGIN" + Header always set X-XSS-Protection "1; mode=block" + Header always set Referrer-Policy "strict-origin-when-cross-origin" + + # Max upload size: 100 MB + LimitRequestBody 104857600 + + ProxyPreserveHost On + ProxyRequests Off + + # ---------------------------------------------------------------- + # FastAPI backend — Apache strips the /ppt-tool prefix so FastAPI + # receives plain /api/v1/... paths (it has no basePath awareness) + # ---------------------------------------------------------------- + + ProxyPass /ppt-tool/api/v1/ http://127.0.0.1:API_PORT/api/v1/ timeout=1800 + ProxyPassReverse /ppt-tool/api/v1/ http://127.0.0.1:API_PORT/api/v1/ + + # Swagger / OpenAPI + ProxyPass /ppt-tool/docs http://127.0.0.1:API_PORT/docs + ProxyPassReverse /ppt-tool/docs http://127.0.0.1:API_PORT/docs + ProxyPass /ppt-tool/openapi.json http://127.0.0.1:API_PORT/openapi.json + ProxyPassReverse /ppt-tool/openapi.json http://127.0.0.1:API_PORT/openapi.json + + # Static files served by FastAPI + ProxyPass /ppt-tool/app_data/ http://127.0.0.1:API_PORT/app_data/ + ProxyPassReverse /ppt-tool/app_data/ http://127.0.0.1:API_PORT/app_data/ + ProxyPass /ppt-tool/static/ http://127.0.0.1:API_PORT/static/ + ProxyPassReverse /ppt-tool/static/ http://127.0.0.1:API_PORT/static/ + + # SSE: disable buffering so streaming responses reach client immediately + + RequestHeader set Connection "" + SetEnv proxy-sendchunked 1 + SetEnv no-gzip 1 + ProxyTimeout 1800 + + + # ---------------------------------------------------------------- + # Next.js frontend — receives /ppt-tool/... paths intact + # (Next.js basePath="/ppt-tool" handles the prefix internally) + # Must come AFTER all /api/ and /static/ rules + # ---------------------------------------------------------------- + + # WebSocket support (HMR in debug, or any WS the app uses) + RewriteEngine On + RewriteCond %{HTTP:Upgrade} websocket [NC] + RewriteCond %{HTTP:Connection} upgrade [NC] + RewriteRule ^/ppt-tool/(.*) ws://127.0.0.1:WEB_PORT/ppt-tool/$1 [P,L] + + ProxyPass /ppt-tool/ http://127.0.0.1:WEB_PORT/ppt-tool/ + ProxyPassReverse /ppt-tool/ http://127.0.0.1:WEB_PORT/ppt-tool/ + + # Root redirect + RedirectMatch ^/$ /ppt-tool/ + + ErrorLog ${APACHE_LOG_DIR}/deckforge-error.log + CustomLog ${APACHE_LOG_DIR}/deckforge-access.log combined + diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..fc174e2 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env bash +# DeckForge Production Deploy Script +# Idempotent — safe to run multiple times +# Must run as root (needs apt, apache2, ufw) +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; } + +# ── Root check ──────────────────────────────────────────────────────────────── +if [[ $EUID -ne 0 ]]; then + error "This script must be run as root (sudo $0)" + exit 1 +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..." + apt-get install -y "$pkg" -qq + else + info " $cmd: OK" + fi +} + +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..." + apt-get install -y docker-compose-plugin -qq +fi + +# Apache2 +if ! command -v apache2 &>/dev/null; then + info " Installing apache2..." + apt-get install -y apache2 -qq +fi + +# Enable required Apache modules +info " Enabling Apache modules..." +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 own docker containers + local proc_name + proc_name=$(cat "/proc/${pid}/comm" 2>/dev/null || echo "unknown") + + if [[ "$proc_name" == "docker"* ]] || [[ "$proc_name" == "containerd"* ]]; then + info " Port $port ($service): used by Docker (will be replaced on restart)" + return + 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" > "$APACHE_CONF_DST" + +info " Written $APACHE_CONF_DST" + +# Disable default site on first install +if [[ -L /etc/apache2/sites-enabled/000-default.conf ]]; then + a2dissite 000-default.conf -q || true + info " Disabled 000-default.conf" +fi + +a2ensite deckforge.conf -q +info " Enabled deckforge.conf" + +# Test Apache config before reloading +if apache2ctl configtest 2>&1 | grep -q "Syntax OK"; then + systemctl reload apache2 + info " Apache reloaded" +else + error "Apache config test failed — check $APACHE_CONF_DST" + apache2ctl configtest + exit 1 +fi + +# ───────────────────────────────────────────────────────────────────────────── +# STEP 11: UFW Firewall +# ───────────────────────────────────────────────────────────────────────────── +info "Step 11: Configuring UFW firewall..." +ufw default deny incoming -y 2>/dev/null || true +ufw default allow outgoing -y 2>/dev/null || true +ufw allow 22/tcp +ufw allow 80/tcp +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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..5ac1ce4 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,36 @@ +# Production overrides for DeckForge +# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml +# +# Changes from base: +# - All ports bound to 127.0.0.1 (Apache reverse proxy, Docker bypasses UFW otherwise) +# - postgres/redis ports removed (internal Docker network only) +# - nginx service excluded (Apache on host replaces it) +# - PYTHONUNBUFFERED=1 on api for log visibility +# - basePath env vars for Next.js + +services: + postgres: + ports: [] + + redis: + ports: [] + + api: + ports: + - "127.0.0.1:${API_PORT:-8000}:8000" + environment: + PYTHONUNBUFFERED: "1" + + worker: + environment: + PYTHONUNBUFFERED: "1" + + web: + ports: + - "127.0.0.1:${WEB_PORT:-3000}:3000" + environment: + NEXT_PUBLIC_BASE_PATH: "/ppt-tool" + + nginx: + profiles: + - disabled