From fc4688cc6fef44a48b9e2efc6f8343462824896a Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 23 Feb 2026 13:41:29 +0000 Subject: [PATCH] feat: add production deploy script and docker-compose - deploy.sh: idempotent deploy for Ubuntu server (Docker + Nginx + SSL) - docker-compose.prod.yml: production compose (runner target, localhost-only port) - .gitignore: exclude .env.production and .deploy-config Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + deploy.sh | 368 ++++++++++++++++++++++++++++++++++++++++ docker-compose.prod.yml | 31 ++++ 3 files changed, 403 insertions(+) create mode 100755 deploy.sh create mode 100644 docker-compose.prod.yml diff --git a/.gitignore b/.gitignore index 00d3713..b088e74 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,10 @@ yarn-error.log* # env files .env.local .env.*.local +.env.production + +# deploy script runtime config (contains domain + email, generated on server) +.deploy-config # vercel .vercel diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..9abbc0d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,368 @@ +#!/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) + local len="${1:-64}" + LC_ALL=C tr -dc 'A-Za-z0-9!@#%^&*()-_=+' /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 $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" < "$DEPLOY_CONFIG" < "$NGINX_CONF" </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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fc0e82f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,31 @@ +services: + app: + build: + context: . + target: runner + restart: always + ports: + - '127.0.0.1:3000:3000' + env_file: + - .env.production + depends_on: + db: + condition: service_healthy + + db: + image: postgres:17-alpine + restart: always + environment: + POSTGRES_USER: ${DB_USER:-axil} + POSTGRES_PASSWORD: ${DB_PASSWORD} + POSTGRES_DB: ${DB_NAME:-axil} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U ${DB_USER:-axil}'] + interval: 5s + timeout: 5s + retries: 10 + +volumes: + pgdata: