Add production deploy script and Apache/Docker config
- deploy.sh: idempotent 12-step deploy for Ubuntu (prereqs, port conflict check, basePath patch, build, migrate, Apache + UFW setup) - docker-compose.prod.yml: prod overrides (127.0.0.1 bindings, no nginx) - apache/deckforge.conf: reverse proxy template with SSE support Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
587f5ef6e1
commit
7c8b8cd369
3 changed files with 401 additions and 0 deletions
68
apache/deckforge.conf
Normal file
68
apache/deckforge.conf
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# DeckForge Apache Virtual Host
|
||||
# Reverse proxy to Docker services on localhost
|
||||
# SSL is terminated by the upstream load balancer
|
||||
|
||||
<VirtualHost *:80>
|
||||
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
|
||||
<Location /ppt-tool/api/v1/>
|
||||
RequestHeader set Connection ""
|
||||
SetEnv proxy-sendchunked 1
|
||||
SetEnv no-gzip 1
|
||||
ProxyTimeout 1800
|
||||
</Location>
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 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
|
||||
</VirtualHost>
|
||||
297
deploy.sh
Executable file
297
deploy.sh
Executable file
|
|
@ -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
|
||||
36
docker-compose.prod.yml
Normal file
36
docker-compose.prod.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Production overrides for DeckForge
|
||||
# Usage: docker compose -f docker-compose.yml -f docker-compose.prod.yml <command>
|
||||
#
|
||||
# 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
|
||||
Loading…
Add table
Reference in a new issue