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 <noreply@anthropic.com>
This commit is contained in:
parent
7bb25f9c59
commit
fc4688cc6f
3 changed files with 403 additions and 0 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
368
deploy.sh
Executable file
368
deploy.sh
Executable file
|
|
@ -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/urandom | head -c "$len"
|
||||
}
|
||||
|
||||
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
|
||||
31
docker-compose.prod.yml
Normal file
31
docker-compose.prod.yml
Normal file
|
|
@ -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:
|
||||
Loading…
Add table
Reference in a new issue