Axil_website/deploy.sh
Vadym Samoilenko d34071f9f8 fix: switch from nginx to Traefik for routing and SSL
Server uses Traefik (traefik-public network) with Cloudflare DNS
cert resolver. Nginx not needed. Add Traefik labels to app service,
connect to traefik-public + internal networks, remove nginx/certbot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 14:39:32 +00:00

332 lines
12 KiB
Bash
Executable file

#!/usr/bin/env bash
# =============================================================================
# deploy.sh — Axil Accountants production deployment
# Idempotent: safe for first-time setup and subsequent updates.
# Usage: ./deploy.sh
# =============================================================================
set -euo pipefail
# ─── Constants ────────────────────────────────────────────────────────────────
PROJECT_DIR="/opt/03-business/Axil"
COMPOSE_FILE="$PROJECT_DIR/docker-compose.prod.yml"
ENV_FILE="$PROJECT_DIR/.env.production"
DEPLOY_CONFIG="$PROJECT_DIR/.deploy-config"
# Routing: Traefik (traefik-public network) — no nginx needed
# ─── Colors ───────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
log() { echo -e "${GREEN}[DEPLOY]${NC} $1"; }
info() { echo -e "${CYAN}[INFO]${NC} $1"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
header(){ echo -e "\n${BOLD}${CYAN}══════════════════════════════════════════════${NC}"; \
echo -e "${BOLD}${CYAN} $1${NC}"; \
echo -e "${BOLD}${CYAN}══════════════════════════════════════════════${NC}"; }
# ─── Helpers ──────────────────────────────────────────────────────────────────
gen_secret() {
# Generate a random string of given length (default 64)
# set +o pipefail to prevent SIGPIPE from tr being treated as an error
local len="${1:-64}"
local result
set +o pipefail
result=$(LC_ALL=C tr -dc 'A-Za-z0-9@#%^_=+' </dev/urandom | head -c "$len")
set -o pipefail
printf '%s' "$result"
}
prompt_with_default() {
# Usage: prompt_with_default "Label" "default_value" → sets REPLY
local label="$1"
local default="$2"
if [ -n "$default" ]; then
read -rp " ${label} [${default}]: " REPLY
REPLY="${REPLY:-$default}"
else
read -rp " ${label}: " REPLY
fi
}
prompt_secret() {
# Usage: prompt_secret "Label" → sets REPLY (hidden input)
local label="$1"
read -rsp " ${label}: " REPLY
echo
}
wait_for_db() {
local max_wait=60
local elapsed=0
local compose_cmd="docker compose -f $COMPOSE_FILE --env-file $ENV_FILE"
log "Waiting for database to be healthy..."
while ! $compose_cmd exec -T db pg_isready -U "$(grep ^DB_USER= "$ENV_FILE" | cut -d= -f2)" -q 2>/dev/null; do
if [ "$elapsed" -ge "$max_wait" ]; then
error "Database did not become healthy within ${max_wait}s. Check logs: docker compose -f $COMPOSE_FILE logs db"
fi
sleep 2
elapsed=$((elapsed + 2))
done
log "Database is healthy."
}
# =============================================================================
# Phase 0 — Prerequisites
# =============================================================================
check_prerequisites() {
header "Phase 0 — Prerequisites"
# Must run as root or with sudo access for nginx/certbot
if [ "$EUID" -ne 0 ]; then
error "Please run as root or with sudo: sudo ./deploy.sh"
fi
command -v docker >/dev/null 2>&1 || error "Docker is not installed. Install it from https://docs.docker.com/engine/install/ubuntu/"
docker compose version >/dev/null 2>&1 || error "Docker Compose plugin not found. Ensure Docker Engine >= 23."
command -v git >/dev/null 2>&1 || error "Git is not installed. Run: apt-get install -y git"
if ! command -v nginx >/dev/null 2>&1; then
log "Nginx not found — installing..."
apt-get update -qq && apt-get install -y nginx
fi
if ! command -v certbot >/dev/null 2>&1; then
log "Certbot not found — installing..."
apt-get update -qq && apt-get install -y certbot python3-certbot-nginx
fi
log "All prerequisites satisfied."
}
# =============================================================================
# Phase 1 — Code sync
# =============================================================================
sync_code() {
header "Phase 1 — Code sync"
if [ ! -d "$PROJECT_DIR/.git" ]; then
error "Project directory $PROJECT_DIR is not a git repository.\nClone the repo first:\n git clone <repo-url> $PROJECT_DIR"
fi
log "Pulling latest code..."
git -C "$PROJECT_DIR" fetch --all
git -C "$PROJECT_DIR" reset --hard origin/main
log "Code is up to date ($(git -C "$PROJECT_DIR" rev-parse --short HEAD))."
}
# =============================================================================
# Phase 2 — Environment setup (first run only)
# =============================================================================
setup_env() {
header "Phase 2 — Environment setup"
if [ -f "$ENV_FILE" ]; then
log ".env.production already exists — skipping interactive setup."
# Load DOMAIN and ADMIN_EMAIL for later phases
if [ -f "$DEPLOY_CONFIG" ]; then
# shellcheck disable=SC1090
source "$DEPLOY_CONFIG"
fi
return
fi
echo -e "\n${BOLD}First-time setup — please provide the following values.${NC}"
echo -e "${YELLOW}Press Enter to accept defaults where shown.${NC}\n"
# Domain
prompt_with_default "Domain (without https://)" "axil.ai-impress.com"
DOMAIN="$REPLY"
# Admin email for Let's Encrypt
prompt_with_default "Admin email (for SSL certificate alerts)" ""
ADMIN_EMAIL="$REPLY"
[ -z "$ADMIN_EMAIL" ] && error "Admin email is required for SSL certificate registration."
# DB credentials
prompt_with_default "Database user" "axil"
DB_USER="$REPLY"
prompt_with_default "Database name" "axil"
DB_NAME="$REPLY"
local suggested_pass
suggested_pass="$(gen_secret 24)"
echo -e "\n ${CYAN}Suggested DB password (auto-generated):${NC} ${suggested_pass}"
prompt_with_default "Database password (Enter to use suggested)" "$suggested_pass"
DB_PASSWORD="$REPLY"
# Payload secret
local suggested_secret
suggested_secret="$(gen_secret 64)"
echo -e "\n ${CYAN}Payload CMS secret (auto-generated 64-char):${NC}"
echo -e " ${suggested_secret}"
prompt_with_default "Payload secret (Enter to use auto-generated)" "$suggested_secret"
PAYLOAD_SECRET="$REPLY"
# Optional integrations
echo -e "\n ${YELLOW}Optional — press Enter to skip any of these:${NC}"
prompt_with_default "Resend API key (email service)" ""
RESEND_API_KEY="$REPLY"
prompt_with_default "UploadThing secret" ""
UPLOADTHING_SECRET="$REPLY"
prompt_with_default "UploadThing app ID" ""
UPLOADTHING_APP_ID="$REPLY"
# Write .env.production
DATABASE_URI="postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}"
NEXT_PUBLIC_SITE_URL="https://${DOMAIN}"
cat > "$ENV_FILE" <<EOF
# Generated by deploy.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ")
# DO NOT commit this file to version control.
NODE_ENV=production
NEXT_TELEMETRY_DISABLED=1
# Database
DB_USER=${DB_USER}
DB_PASSWORD=${DB_PASSWORD}
DB_NAME=${DB_NAME}
DATABASE_URI=${DATABASE_URI}
# Payload CMS
PAYLOAD_SECRET=${PAYLOAD_SECRET}
# Public URL
NEXT_PUBLIC_SITE_URL=${NEXT_PUBLIC_SITE_URL}
# Email (optional)
RESEND_API_KEY=${RESEND_API_KEY}
# File uploads (optional)
UPLOADTHING_SECRET=${UPLOADTHING_SECRET}
UPLOADTHING_APP_ID=${UPLOADTHING_APP_ID}
EOF
chmod 600 "$ENV_FILE"
# Persist deploy config (domain/email) for subsequent nginx runs
cat > "$DEPLOY_CONFIG" <<EOF
DOMAIN=${DOMAIN}
ADMIN_EMAIL=${ADMIN_EMAIL}
EOF
chmod 600 "$DEPLOY_CONFIG"
log ".env.production created at $ENV_FILE"
log "Deploy config saved at $DEPLOY_CONFIG"
}
# =============================================================================
# Phase 3 — Docker build
# =============================================================================
build_containers() {
header "Phase 3 — Docker build"
log "Building production Docker image (with layer cache, pulling fresh base images)..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" build --pull
log "Build complete."
}
# =============================================================================
# Phase 4 — Start containers
# =============================================================================
start_containers() {
header "Phase 4 — Start containers"
log "Starting containers..."
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d
wait_for_db
}
# =============================================================================
# Phase 5 — Database migrations
# =============================================================================
run_migrations() {
header "Phase 5 — Database migrations"
# Migrations run automatically via src/instrumentation.ts on Next.js startup.
# We wait for the app container to be healthy (confirming startup completed).
log "Waiting for app container to pass health check (migrations run at startup)..."
local max_wait=120
local elapsed=0
while ! docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T app \
node -e "process.exit(0)" >/dev/null 2>&1; do
if [ "$elapsed" -ge "$max_wait" ]; then
warn "App health check timed out — check logs: docker compose -f $COMPOSE_FILE logs app"
return
fi
sleep 3
elapsed=$((elapsed + 3))
done
log "App is running. Migrations were applied at startup via instrumentation.ts"
}
# =============================================================================
# Phase 6 — Traefik registration (automatic via Docker labels)
# =============================================================================
setup_nginx() {
header "Phase 6 — Traefik routing"
# Load domain from deploy config
if [ -f "$DEPLOY_CONFIG" ]; then
# shellcheck disable=SC1090
source "$DEPLOY_CONFIG"
fi
log "Routing handled by Traefik via Docker labels."
log "SSL certificate will be issued automatically via Cloudflare DNS resolver."
info "Route: https://${DOMAIN:-axil.ai-impress.com} → axil-app-1:3000"
}
# =============================================================================
# Phase 7 — Final status
# =============================================================================
print_status() {
header "Deployment complete"
# Load domain
local domain="(unknown)"
if [ -f "$DEPLOY_CONFIG" ]; then
# shellcheck disable=SC1090
source "$DEPLOY_CONFIG"
domain="$DOMAIN"
fi
echo
log "Container status:"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" ps
echo
echo -e "${GREEN}${BOLD} Site: https://${domain}${NC}"
echo -e "${GREEN}${BOLD} Admin: https://${domain}/admin${NC}"
echo -e "${GREEN}${BOLD} Logs: docker compose -f $COMPOSE_FILE logs -f app${NC}"
echo
}
# =============================================================================
# Main
# =============================================================================
main() {
echo -e "\n${BOLD}${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${BOLD}${GREEN}║ Axil Accountants — Deploy Script ║${NC}"
echo -e "${BOLD}${GREEN}╚══════════════════════════════════════════════╝${NC}"
check_prerequisites
sync_code
setup_env
build_containers
start_containers
run_migrations
setup_nginx
print_status
}
main