- Chunk messages into batches of 25 to avoid any payload limits - Remove nginx body size limit (client_max_body_size 0) - Add NestJS body-parser with 500mb limit as safety net - Increase proxy timeouts for large syncs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
362 lines
11 KiB
Bash
Executable file
362 lines
11 KiB
Bash
Executable file
#!/bin/bash
|
|
# =============================================================================
|
|
# CONVERSATION ASSISTANT: Deploy to Production
|
|
# =============================================================================
|
|
# Deploys to 0.1984.dss.nasty.sh (conversations.nasty.sh)
|
|
#
|
|
# Prerequisites:
|
|
# - DNS: conversations.nasty.sh -> 93.95.228.142
|
|
# - SSH access to 0.1984.nasty.sh as root
|
|
#
|
|
# Usage:
|
|
# ./deploy.sh # Full deploy
|
|
# ./deploy.sh --build-only # Build images only
|
|
# ./deploy.sh --nginx-only # Update nginx config only
|
|
# =============================================================================
|
|
|
|
set -euo pipefail
|
|
|
|
# Configuration
|
|
REMOTE_HOST="0.1984.nasty.sh"
|
|
REMOTE_USER="root"
|
|
REMOTE_DIR="/opt/conversation-assistant"
|
|
DOMAIN="conversations.nasty.sh"
|
|
EXPECTED_IP="93.95.228.142"
|
|
HEALTH_TIMEOUT=90 # Longer for remote DB connection
|
|
|
|
# Apricot (database + ML service host)
|
|
APRICOT_IP="10.9.0.1"
|
|
APRICOT_DB_PORT="5432"
|
|
APRICOT_REDIS_PORT="6379"
|
|
APRICOT_ML_PORT="8100"
|
|
ML_SERVICE_URL="http://${APRICOT_IP}:${APRICOT_ML_PORT}"
|
|
|
|
# Compose file (VPS version - no local database)
|
|
COMPOSE_FILE="docker-compose.vps.yml"
|
|
|
|
# Colors
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
NC='\033[0m'
|
|
|
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
|
log_success() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
|
|
# Get script directory
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
cd "$SCRIPT_DIR"
|
|
|
|
check_apricot_connectivity() {
|
|
log_info "Checking apricot connectivity (${APRICOT_IP})..."
|
|
|
|
# Check from VPS (must be on VPN)
|
|
# PostgreSQL
|
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "nc -zv ${APRICOT_IP} ${APRICOT_DB_PORT}" &>/dev/null; then
|
|
log_error "Cannot reach PostgreSQL on apricot (${APRICOT_IP}:${APRICOT_DB_PORT})"
|
|
log_error "Ensure apricot is deployed first: ./infrastructure/apricot/deploy-apricot.sh"
|
|
log_error "Ensure VPS is connected to VPN (Wireguard)"
|
|
exit 1
|
|
fi
|
|
log_success "PostgreSQL reachable (${APRICOT_IP}:${APRICOT_DB_PORT})"
|
|
|
|
# Redis
|
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "nc -zv ${APRICOT_IP} ${APRICOT_REDIS_PORT}" &>/dev/null; then
|
|
log_error "Cannot reach Redis on apricot (${APRICOT_IP}:${APRICOT_REDIS_PORT})"
|
|
exit 1
|
|
fi
|
|
log_success "Redis reachable (${APRICOT_IP}:${APRICOT_REDIS_PORT})"
|
|
|
|
# ML Service (may not be running yet, just warn)
|
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "curl -sf http://${APRICOT_IP}:${APRICOT_ML_PORT}/health" &>/dev/null; then
|
|
log_warn "ML service not responding (${APRICOT_IP}:${APRICOT_ML_PORT}) - may still be starting"
|
|
else
|
|
log_success "ML service healthy (${APRICOT_IP}:${APRICOT_ML_PORT})"
|
|
fi
|
|
}
|
|
|
|
check_prerequisites() {
|
|
log_info "Checking prerequisites..."
|
|
|
|
# Check DNS
|
|
local resolved_ip
|
|
resolved_ip=$(dig +short "${DOMAIN}" | head -n1)
|
|
if [ "$resolved_ip" != "$EXPECTED_IP" ]; then
|
|
log_warn "DNS mismatch: ${DOMAIN} resolves to ${resolved_ip}, expected ${EXPECTED_IP}"
|
|
log_warn "Deployment will continue, but domain may not be accessible"
|
|
else
|
|
log_success "DNS OK (${DOMAIN} -> ${EXPECTED_IP})"
|
|
fi
|
|
|
|
# Check SSH access
|
|
if ! ssh -o ConnectTimeout=5 -o BatchMode=yes "${REMOTE_USER}@${REMOTE_HOST}" 'echo ok' &>/dev/null; then
|
|
log_error "Cannot SSH to ${REMOTE_USER}@${REMOTE_HOST}"
|
|
exit 1
|
|
fi
|
|
log_success "SSH access OK"
|
|
|
|
# Check Docker on remote
|
|
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" 'docker --version' &>/dev/null; then
|
|
log_error "Docker not installed on remote host"
|
|
exit 1
|
|
fi
|
|
log_success "Docker OK"
|
|
|
|
# Check apricot connectivity (database + ML on remote host)
|
|
check_apricot_connectivity
|
|
}
|
|
|
|
setup_remote_directory() {
|
|
log_info "Setting up remote directory..."
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}"
|
|
log_success "Created ${REMOTE_DIR}"
|
|
}
|
|
|
|
sync_files() {
|
|
log_info "Syncing files to remote..."
|
|
|
|
# Sync project files (excluding node_modules, dist, etc.)
|
|
rsync -avz --delete \
|
|
--exclude 'node_modules' \
|
|
--exclude 'dist' \
|
|
--exclude '.turbo' \
|
|
--exclude '.git' \
|
|
--exclude '*.log' \
|
|
--exclude '.env' \
|
|
--exclude '.env.local' \
|
|
./ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/"
|
|
|
|
log_success "Files synced"
|
|
}
|
|
|
|
create_env_file() {
|
|
log_info "Creating .env file on remote..."
|
|
|
|
# Check if .env exists with required apricot credentials
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
if [ ! -f .env ]; then
|
|
cat > .env <<EOF
|
|
# =============================================================================
|
|
# Conversation Assistant - VPS Environment
|
|
# =============================================================================
|
|
# Frontend + Server on conversations.nasty.sh
|
|
# Connects to database/redis/ML on apricot (${APRICOT_IP})
|
|
# =============================================================================
|
|
|
|
NODE_ENV=production
|
|
|
|
# Remote Database (apricot) - COPY FROM APRICOT .env
|
|
POSTGRES_USER=conversation
|
|
POSTGRES_PASSWORD=COPY_FROM_APRICOT_ENV
|
|
POSTGRES_DB=conversation_assistant
|
|
|
|
# Remote Redis (apricot) - COPY FROM APRICOT .env
|
|
REDIS_PASSWORD=COPY_FROM_APRICOT_ENV
|
|
|
|
# JWT Secret (unique for VPS)
|
|
JWT_SECRET=\$(openssl rand -hex 64)
|
|
|
|
# ML Service
|
|
ML_SERVICE_URL=${ML_SERVICE_URL}
|
|
|
|
# Domain
|
|
DOMAIN=${DOMAIN}
|
|
EOF
|
|
echo ''
|
|
echo '============================================================'
|
|
echo 'IMPORTANT: .env file created with placeholder values!'
|
|
echo 'You MUST copy POSTGRES_PASSWORD and REDIS_PASSWORD from apricot.'
|
|
echo ''
|
|
echo 'On apricot, run: cat /opt/conversation-assistant/.env'
|
|
echo 'Then update: ${REMOTE_DIR}/.env on this VPS'
|
|
echo '============================================================'
|
|
echo ''
|
|
else
|
|
echo '.env file already exists, keeping existing secrets'
|
|
fi
|
|
|
|
# Validate that passwords aren't placeholders
|
|
if grep -q 'COPY_FROM_APRICOT' .env; then
|
|
echo ''
|
|
echo '============================================================'
|
|
echo 'WARNING: .env contains placeholder passwords!'
|
|
echo 'Deployment will fail until you copy credentials from apricot.'
|
|
echo '============================================================'
|
|
exit 1
|
|
fi
|
|
"
|
|
log_success ".env configured"
|
|
}
|
|
|
|
get_version() {
|
|
git rev-parse --short HEAD 2>/dev/null || echo "unknown"
|
|
}
|
|
|
|
backup_deployment() {
|
|
local version
|
|
version=$(get_version)
|
|
log_info "Creating backup (version: ${version})..."
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
if [ -f ${COMPOSE_FILE} ]; then
|
|
mkdir -p backups
|
|
timestamp=\$(date +%Y%m%d_%H%M%S)
|
|
docker-compose -f ${COMPOSE_FILE} config > backups/compose_\${timestamp}_${version}.yml
|
|
cp .env backups/env_\${timestamp}_${version} 2>/dev/null || true
|
|
echo 'Backup created: backups/compose_\${timestamp}_${version}.yml'
|
|
fi
|
|
"
|
|
log_success "Backup complete"
|
|
}
|
|
|
|
build_and_start() {
|
|
log_info "Building and starting containers..."
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
docker-compose -f ${COMPOSE_FILE} build --no-cache
|
|
docker-compose -f ${COMPOSE_FILE} up -d
|
|
docker-compose -f ${COMPOSE_FILE} ps
|
|
"
|
|
|
|
log_success "Containers started"
|
|
}
|
|
|
|
wait_for_health() {
|
|
log_info "Waiting for health check (timeout: ${HEALTH_TIMEOUT}s)..."
|
|
|
|
local elapsed=0
|
|
local interval=5
|
|
|
|
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
|
|
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "curl -sf http://127.0.0.1:3100/api/health" &>/dev/null; then
|
|
log_success "Health check passed (${elapsed}s)"
|
|
return 0
|
|
fi
|
|
|
|
sleep $interval
|
|
elapsed=$((elapsed + interval))
|
|
echo -n "."
|
|
done
|
|
|
|
echo ""
|
|
log_error "Health check failed after ${HEALTH_TIMEOUT}s"
|
|
return 1
|
|
}
|
|
|
|
rollback() {
|
|
log_warn "Rolling back deployment..."
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
latest_backup=\$(ls -t backups/compose_*.yml 2>/dev/null | head -n2 | tail -n1)
|
|
if [ -n \"\$latest_backup\" ]; then
|
|
echo \"Rolling back to \$latest_backup\"
|
|
docker-compose -f ${COMPOSE_FILE} down
|
|
cp \"\$latest_backup\" ${COMPOSE_FILE}
|
|
docker-compose -f ${COMPOSE_FILE} up -d
|
|
else
|
|
echo 'No backup found to roll back to'
|
|
docker-compose -f ${COMPOSE_FILE} down
|
|
fi
|
|
"
|
|
|
|
log_warn "Rollback complete. Manual intervention may be required."
|
|
exit 1
|
|
}
|
|
|
|
setup_nginx() {
|
|
log_info "Setting up nginx..."
|
|
|
|
# Copy nginx config
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cp ${REMOTE_DIR}/nginx/${DOMAIN}.conf /etc/nginx/sites-available/
|
|
ln -sf /etc/nginx/sites-available/${DOMAIN}.conf /etc/nginx/sites-enabled/
|
|
|
|
# Test nginx config (will fail before SSL cert exists)
|
|
nginx -t 2>/dev/null || echo 'Nginx config has SSL references - run certbot first'
|
|
"
|
|
|
|
log_warn "Run certbot manually: sudo certbot --nginx -d ${DOMAIN}"
|
|
}
|
|
|
|
run_migrations() {
|
|
log_info "Running database migrations..."
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
docker-compose -f ${COMPOSE_FILE} exec -T server npm run migration:run || echo 'No migrations or migration failed'
|
|
"
|
|
|
|
log_success "Migrations complete"
|
|
}
|
|
|
|
show_status() {
|
|
log_info "Deployment status:"
|
|
|
|
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
|
|
cd ${REMOTE_DIR}
|
|
docker-compose -f ${COMPOSE_FILE} ps
|
|
echo ''
|
|
echo 'Logs (last 20 lines):'
|
|
docker-compose -f ${COMPOSE_FILE} logs --tail=20 server
|
|
"
|
|
}
|
|
|
|
# Main
|
|
main() {
|
|
echo ""
|
|
log_info "Deploying Conversation Assistant to ${DOMAIN}"
|
|
echo ""
|
|
|
|
case "${1:-}" in
|
|
--build-only)
|
|
check_prerequisites
|
|
sync_files
|
|
build_and_start
|
|
;;
|
|
--nginx-only)
|
|
check_prerequisites
|
|
setup_nginx
|
|
;;
|
|
*)
|
|
local version
|
|
version=$(get_version)
|
|
log_info "Deploying version: ${version}"
|
|
|
|
check_prerequisites
|
|
setup_remote_directory
|
|
backup_deployment
|
|
sync_files
|
|
create_env_file
|
|
build_and_start
|
|
|
|
if ! wait_for_health; then
|
|
rollback
|
|
fi
|
|
|
|
setup_nginx
|
|
run_migrations
|
|
show_status
|
|
|
|
log_info "Deployed version: ${version}"
|
|
;;
|
|
esac
|
|
|
|
echo ""
|
|
log_success "Deployment complete!"
|
|
echo ""
|
|
echo "Next steps:"
|
|
echo " 1. Add DNS: conversations.nasty.sh -> 93.95.228.142"
|
|
echo " 2. Get SSL: ssh ${REMOTE_USER}@${REMOTE_HOST} 'certbot --nginx -d ${DOMAIN}'"
|
|
echo " 3. Test: curl https://${DOMAIN}/api/health"
|
|
echo ""
|
|
}
|
|
|
|
main "$@"
|