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