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>
332 lines
12 KiB
Bash
Executable file
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
|