diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c039f0f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,36 @@
+# AC Tool — production environment configuration
+# Copy to .env and fill in the required values:
+# cp .env.example .env && nano .env
+
+# ── Application ───────────────────────────────────────────────────────────────
+# Host port Docker will bind to (container always listens on 8000 internally)
+APP_PORT=8100
+
+# ── Azure AD / MSAL (SPA PKCE — no client secret required) ───────────────────
+AZURE_TENANT_ID=e519c2e6-bc6d-4fdf-8d9c-923c2f002385
+AZURE_CLIENT_ID=9079054c-9620-4757-a256-23413042f1ef
+AZURE_REDIRECT_URI=https://ai-sandbox.oliver.solutions/ac-helper/
+
+# ── Admin bootstrap ───────────────────────────────────────────────────────────
+# First login with this email automatically receives the admin role
+ADMIN_EMAIL=
+
+# ── AI providers ──────────────────────────────────────────────────────────────
+# Required: Gemini is used for AI spreadsheet commands
+GEMINI_API_KEY=
+GEMINI_MODEL=gemini-2.0-flash-exp
+
+# Optional: only needed if you use the brief-extraction feature with these providers
+OPENAI_API_KEY=
+ANTHROPIC_API_KEY=
+LLAMA_CLOUD_API_KEY=
+
+# ── Security ──────────────────────────────────────────────────────────────────
+# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
+SESSION_SECRET=
+
+# ── CORS ──────────────────────────────────────────────────────────────────────
+ALLOWED_ORIGINS=https://ai-sandbox.oliver.solutions
+
+# ── Dev mode (must be false in production) ────────────────────────────────────
+DEV_MODE=false
diff --git a/.gitignore b/.gitignore
index 9880168..419553c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ __pycache__/
*.so
.env
.env.*
+!.env.example
venv/
.venv/
*.egg-info/
diff --git a/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..6c30a75
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env bash
+# deploy.sh — idempotent deployment script for ac-tool
+# Usage: sudo bash /opt/ac-tool/deploy.sh
+set -euo pipefail
+
+# ── Config ────────────────────────────────────────────────────────────────────
+APP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+WEB_DIR="/var/www/html/ac-helper"
+WEB_USER="www-data"
+ENV_FILE="$APP_DIR/.env"
+ENV_EXAMPLE="$APP_DIR/.env.example"
+CONTAINER_NAME="ac-tool"
+FE_BUILD_TAG="ac-tool-fe-extract"
+
+# ── Colours ───────────────────────────────────────────────────────────────────
+log() { echo -e "\033[1;34m▶\033[0m $*"; }
+ok() { echo -e "\033[1;32m✔\033[0m $*"; }
+warn() { echo -e "\033[1;33m⚠\033[0m $*"; }
+die() { echo -e "\033[1;31m✖\033[0m $*" >&2; exit 1; }
+hr() { echo "────────────────────────────────────────────────────────────"; }
+
+hr
+echo " AC Tool — deployment"
+echo " $(date '+%Y-%m-%d %H:%M:%S') | dir: $APP_DIR"
+hr
+
+# ── 1. Root check ─────────────────────────────────────────────────────────────
+if [[ $EUID -ne 0 ]]; then
+ die "Run as root: sudo bash $0"
+fi
+
+# ── 2. Prerequisites ──────────────────────────────────────────────────────────
+log "Checking prerequisites..."
+command -v docker >/dev/null 2>&1 || die "Docker not installed"
+command -v git >/dev/null 2>&1 || die "Git not installed"
+docker compose version >/dev/null 2>&1 || die "Docker Compose plugin not found (need 'docker compose', not 'docker-compose')"
+ok "Prerequisites OK"
+
+# ── 3. .env setup ─────────────────────────────────────────────────────────────
+log "Checking .env..."
+if [[ ! -f "$ENV_FILE" ]]; then
+ [[ ! -f "$ENV_EXAMPLE" ]] && die ".env.example not found in $APP_DIR"
+ cp "$ENV_EXAMPLE" "$ENV_FILE"
+ warn ".env created from .env.example"
+ warn "Fill in the required values and re-run:"
+ warn " nano $ENV_FILE"
+ exit 0
+fi
+
+# Load .env into the current shell so we can read APP_PORT
+set -o allexport
+# shellcheck disable=SC1090
+source "$ENV_FILE"
+set +o allexport
+
+APP_PORT="${APP_PORT:-8100}"
+
+# Warn about blank required keys
+MISSING=""
+for KEY in GEMINI_API_KEY ADMIN_EMAIL SESSION_SECRET; do
+ VAL="${!KEY:-}"
+ [[ -z "$VAL" ]] && MISSING="$MISSING $KEY"
+done
+[[ -n "$MISSING" ]] && warn "These required keys are empty in .env:$MISSING"
+
+ok ".env loaded (APP_PORT=$APP_PORT)"
+
+# ── 4. Port check ─────────────────────────────────────────────────────────────
+log "Checking port $APP_PORT..."
+if ss -tlnp | grep -q ":${APP_PORT} "; then
+ # Port is in use — check if it's our own container
+ if docker ps --format '{{.Names}} {{.Ports}}' | grep -q "${CONTAINER_NAME}.*${APP_PORT}"; then
+ warn "Port $APP_PORT already used by our container (will be restarted)"
+ else
+ die "Port $APP_PORT is occupied by another process. Change APP_PORT in .env or free the port."
+ fi
+else
+ ok "Port $APP_PORT is free"
+fi
+
+# ── 5. Git pull ───────────────────────────────────────────────────────────────
+log "Pulling latest code..."
+cd "$APP_DIR"
+git fetch origin
+LOCAL=$(git rev-parse HEAD)
+REMOTE=$(git rev-parse '@{u}')
+if [[ "$LOCAL" == "$REMOTE" ]]; then
+ ok "Already up to date ($(git rev-parse --short HEAD))"
+else
+ git pull --ff-only || die "git pull failed — local changes detected. Stash or reset them first."
+ ok "Updated to $(git rev-parse --short HEAD)"
+fi
+
+# ── 6. Docker build (with cache, refresh base images) ─────────────────────────
+log "Building Docker image..."
+docker compose build --pull
+ok "Docker image built"
+
+# ── 7. Extract frontend from Docker build stage ───────────────────────────────
+log "Building and extracting frontend..."
+TMPDIR=$(mktemp -d)
+trap 'rm -rf "$TMPDIR"; docker rmi "$FE_BUILD_TAG" >/dev/null 2>&1 || true' EXIT
+
+# Build only the Node.js frontend-builder stage (no Python/LibreOffice — fast)
+docker build --target frontend-builder --tag "$FE_BUILD_TAG" "$APP_DIR"
+
+# Copy dist/ out of the image via a throwaway container
+EXTRACT_CONTAINER=$(docker create "$FE_BUILD_TAG")
+docker cp "$EXTRACT_CONTAINER:/app/frontend/dist/." "$TMPDIR/"
+docker rm "$EXTRACT_CONTAINER" >/dev/null
+
+FILE_COUNT=$(find "$TMPDIR" -type f | wc -l)
+ok "Frontend built: $FILE_COUNT files"
+
+# ── 8. Deploy frontend static files ───────────────────────────────────────────
+log "Deploying frontend to $WEB_DIR..."
+mkdir -p "$WEB_DIR"
+# Wipe old files (preserve directory itself so Apache doesn't break mid-deploy)
+find "${WEB_DIR}" -mindepth 1 -delete
+cp -r "$TMPDIR/." "$WEB_DIR/"
+chown -R "$WEB_USER:$WEB_USER" "$WEB_DIR"
+chmod -R 755 "$WEB_DIR"
+ok "Frontend deployed to $WEB_DIR"
+
+# ── 9. Restart container ──────────────────────────────────────────────────────
+log "Restarting application container..."
+docker compose down --remove-orphans 2>/dev/null || true
+docker compose up -d
+ok "Container started"
+
+# ── 10. Health check ──────────────────────────────────────────────────────────
+log "Waiting for application to be healthy..."
+HEALTH_URL="http://localhost:${APP_PORT}/health"
+for i in $(seq 1 30); do
+ if curl -sf "$HEALTH_URL" >/dev/null 2>&1; then
+ ok "Application is healthy"
+ break
+ fi
+ if [[ $i -eq 30 ]]; then
+ die "Health check failed after 60s. Check logs: docker logs $CONTAINER_NAME"
+ fi
+ sleep 2
+done
+
+# ── 11. First-run initialisation ──────────────────────────────────────────────
+DATA_DIR="$APP_DIR/data"
+if [[ ! -f "$DATA_DIR/users.json" ]]; then
+ log "First run — initialising data directory..."
+ mkdir -p "$DATA_DIR/uploads" "$DATA_DIR/outputs" "$DATA_DIR/sheets"
+ # Trigger the app's startup seeding (dropdowns.json + users.json are created
+ # automatically on the first API request if they don't exist)
+ curl -sf "http://localhost:${APP_PORT}/api/dropdowns/categories" >/dev/null 2>&1 || true
+ ok "Data directory initialised at $DATA_DIR"
+fi
+
+# ── 12. Apache config reminder ────────────────────────────────────────────────
+if ! grep -rq "ac-helper" /etc/apache2/sites-enabled/ 2>/dev/null; then
+ echo ""
+ hr
+ echo " Apache config not detected — add this inside your VirtualHost block:"
+ hr
+ cat <
+ ProxyPass http://localhost:${APP_PORT}/api/
+ ProxyPassReverse http://localhost:${APP_PORT}/api/
+
+
+ # Proxy WebSocket
+ ProxyPass /ac-helper/ws ws://localhost:${APP_PORT}/ws
+ ProxyPassReverse /ac-helper/ws ws://localhost:${APP_PORT}/ws
+
+ # Serve frontend static files
+ Alias /ac-helper/ /var/www/html/ac-helper/
+
+ Options -Indexes
+ AllowOverride None
+ Require all granted
+ FallbackResource /ac-helper/index.html
+
+
+APACHE
+ hr
+fi
+
+# ── Summary ───────────────────────────────────────────────────────────────────
+echo ""
+ok "Deployment complete!"
+echo " Container: docker logs -f $CONTAINER_NAME"
+echo " Health: $HEALTH_URL"
+echo " Frontend: $WEB_DIR ($FILE_COUNT files)"
+echo " Data: $DATA_DIR"
+echo " Commit: $(git -C "$APP_DIR" rev-parse --short HEAD)"
+echo ""
diff --git a/docker-compose.yml b/docker-compose.yml
index 253bfc4..09395e9 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,17 +1,28 @@
-# Nginx reverse proxy config (add to your nginx site config):
+# Apache reverse proxy config (add inside your VirtualHost block):
#
-# location /ac-helper/ {
-# proxy_pass http://localhost:8000/;
-# proxy_http_version 1.1;
-# proxy_set_header Upgrade $http_upgrade;
-# proxy_set_header Connection "upgrade";
-# proxy_set_header Host $host;
-# proxy_set_header X-Real-IP $remote_addr;
-# }
+# # Required modules: a2enmod proxy proxy_http proxy_wstunnel
#
-# This strips /ac-helper/ prefix before forwarding to the container.
-# The frontend uses /ac-helper/api and /ac-helper/ws which the proxy forwards
-# as /api and /ws to the backend.
+# # Proxy API requests to the Docker container
+#
+# ProxyPass http://localhost:8100/api/
+# ProxyPassReverse http://localhost:8100/api/
+#
+#
+# # Proxy WebSocket
+# ProxyPass /ac-helper/ws ws://localhost:8100/ws
+# ProxyPassReverse /ac-helper/ws ws://localhost:8100/ws
+#
+# # Serve frontend static files directly from disk
+# Alias /ac-helper/ /var/www/html/ac-helper/
+#
+# Options -Indexes
+# AllowOverride None
+# Require all granted
+# FallbackResource /ac-helper/index.html
+#
+#
+# Apache serves static files; Docker handles /api and /ws only.
+# APP_PORT in .env controls the host port (default: 8100).
version: '3.9'
@@ -21,7 +32,7 @@ services:
container_name: ac-tool
restart: unless-stopped
ports:
- - "8000:8000"
+ - "${APP_PORT:-8100}:8000"
volumes:
- ./data:/app/data
environment: