tr/head pipe causes SIGPIPE exit 141, killing script silently. Wrap with set +o pipefail guard. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
373 lines
13 KiB
Bash
Executable file
373 lines
13 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"
|
|
NGINX_SITE="axil-accountants"
|
|
NGINX_CONF="/etc/nginx/sites-available/$NGINX_SITE"
|
|
NGINX_ENABLED="/etc/nginx/sites-enabled/$NGINX_SITE"
|
|
|
|
# ─── 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"
|
|
|
|
log "Running Payload CMS migrations..."
|
|
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" exec -T app pnpm payload migrate
|
|
log "Migrations complete."
|
|
}
|
|
|
|
# =============================================================================
|
|
# Phase 6 — Nginx setup (first run only)
|
|
# =============================================================================
|
|
setup_nginx() {
|
|
header "Phase 6 — Nginx setup"
|
|
|
|
# Load domain from deploy config
|
|
if [ ! -f "$DEPLOY_CONFIG" ]; then
|
|
warn "Deploy config not found at $DEPLOY_CONFIG — skipping Nginx setup."
|
|
return
|
|
fi
|
|
# shellcheck disable=SC1090
|
|
source "$DEPLOY_CONFIG"
|
|
|
|
if [ -f "$NGINX_CONF" ]; then
|
|
log "Nginx config already exists at $NGINX_CONF — skipping creation."
|
|
else
|
|
log "Writing Nginx config for ${DOMAIN}..."
|
|
cat > "$NGINX_CONF" <<NGINX
|
|
server {
|
|
listen 80;
|
|
listen [::]:80;
|
|
server_name ${DOMAIN} www.${DOMAIN};
|
|
|
|
# Allow large file uploads (Payload CMS media)
|
|
client_max_body_size 50M;
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:3000;
|
|
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;
|
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
proxy_cache_bypass \$http_upgrade;
|
|
proxy_read_timeout 60s;
|
|
}
|
|
}
|
|
NGINX
|
|
|
|
# Enable the site
|
|
ln -sf "$NGINX_CONF" "$NGINX_ENABLED"
|
|
log "Nginx site enabled."
|
|
fi
|
|
|
|
# Test and reload Nginx
|
|
nginx -t
|
|
systemctl reload nginx
|
|
log "Nginx reloaded."
|
|
|
|
# SSL via Certbot
|
|
if certbot certificates 2>/dev/null | grep -q "Domains.*${DOMAIN}"; then
|
|
log "SSL certificate for ${DOMAIN} already exists — skipping Certbot."
|
|
else
|
|
log "Obtaining SSL certificate for ${DOMAIN} (and www.${DOMAIN})..."
|
|
certbot --nginx \
|
|
-d "${DOMAIN}" \
|
|
-d "www.${DOMAIN}" \
|
|
--non-interactive \
|
|
--agree-tos \
|
|
-m "${ADMIN_EMAIL}" \
|
|
--redirect
|
|
log "SSL certificate obtained. HTTP → HTTPS redirect enabled."
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# 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
|