Add deploy script, .env.example, and Apache reverse proxy config

- deploy.sh: idempotent Ubuntu deployment (git pull → docker build →
  extract frontend → copy to /var/www/html/ac-helper/ → restart container)
- .env.example: production template with APP_PORT=8100
- docker-compose.yml: port now ${APP_PORT:-8100}:8000, updated proxy
  comment to Apache VirtualHost snippet
- .gitignore: whitelist .env.example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-23 14:05:33 +00:00
parent 38a550bd5f
commit dad8f7573a
4 changed files with 258 additions and 13 deletions

36
.env.example Normal file
View file

@ -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

1
.gitignore vendored
View file

@ -4,6 +4,7 @@ __pycache__/
*.so
.env
.env.*
!.env.example
venv/
.venv/
*.egg-info/

197
deploy.sh Executable file
View file

@ -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 <<APACHE
# Required: a2enmod proxy proxy_http proxy_wstunnel
# Proxy API to Docker
<Location /ac-helper/api/>
ProxyPass http://localhost:${APP_PORT}/api/
ProxyPassReverse http://localhost:${APP_PORT}/api/
</Location>
# 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/
<Directory /var/www/html/ac-helper>
Options -Indexes
AllowOverride None
Require all granted
FallbackResource /ac-helper/index.html
</Directory>
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 ""

View file

@ -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
# <Location /ac-helper/api/>
# ProxyPass http://localhost:8100/api/
# ProxyPassReverse http://localhost:8100/api/
# </Location>
#
# # 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/
# <Directory /var/www/html/ac-helper>
# Options -Indexes
# AllowOverride None
# Require all granted
# FallbackResource /ac-helper/index.html
# </Directory>
#
# 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: