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:
Vadym Samoilenko 2026-02-23 13:41:29 +00:00
parent 7bb25f9c59
commit fc4688cc6f
3 changed files with 403 additions and 0 deletions

4
.gitignore vendored
View file

@ -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
View 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
View 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: