#!/usr/bin/env bash set -euo pipefail # Deploy source code to the production server via rsync. # # Usage: # # Full deploy (sync entire project) # ./scripts/rsync-deploy.sh # ./scripts/rsync-deploy.sh --dry-run # # # Deploy a specific directory # ./scripts/rsync-deploy.sh src/ # ./scripts/rsync-deploy.sh src/payload/ # # # Deploy one or more files # ./scripts/rsync-deploy.sh src/payload/globals/Homepage.ts # ./scripts/rsync-deploy.sh src/payload/globals/Homepage.ts src/app/sitemap.ts # # # Mix directories and files # ./scripts/rsync-deploy.sh src/migrations/ src/payload/globals/Homepage.ts # # Options: # --dry-run, -n Show what would be transferred without making changes # --target HOST SSH target (default: luxsin-website or $DEPLOY_TARGET) # --port PORT SSH port (default: 22 or $SSH_PORT) # --delete Enable --delete for specific-path syncs (full deploy always uses --delete) # --help, -h Show this help message # # Environment variables: # DEPLOY_TARGET SSH target (default: luxsin-website) # REMOTE_DIR Remote project root (default: /data/project/eversolo-app) # SSH_PORT Custom SSH port (default: 22) ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" DEPLOY_TARGET="${DEPLOY_TARGET:-luxsin-website}" REMOTE_DIR="${REMOTE_DIR:-/data/project/eversolo-app}" SSH_PORT="${SSH_PORT:-22}" DRY_RUN=0 FORCE_DELETE=0 PATHS=() # ── Parse arguments ────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --dry-run|-n) DRY_RUN=1 shift ;; --target|-t) DEPLOY_TARGET="$2" shift 2 ;; --port|-p) SSH_PORT="$2" shift 2 ;; --delete) FORCE_DELETE=1 shift ;; --help|-h) sed -n '3,/^$/p' "$0" | sed 's/^# \{0,1\}//' exit 0 ;; --*) echo "ERROR: Unknown option: $1" >&2 exit 1 ;; *) PATHS+=("$1") shift ;; esac done # ── Verify rsync is available ──────────────────────────────────── if ! command -v rsync >/dev/null 2>&1; then echo "ERROR: rsync is not installed." >&2 exit 1 fi SSH_CMD="ssh -p ${SSH_PORT}" # ── Full-deploy excludes ───────────────────────────────────────── # Only upload files needed to build and run the Docker deployment. # Server-managed files (.env, data/, media/, etc.) are excluded and # will NOT be deleted by --delete on the remote. FULL_EXCLUDES=( # Build artifacts --exclude='.next/' --exclude='.next-dev/' --exclude='.turbo/' --exclude='tsconfig.tsbuildinfo' --exclude='out/' # Dependencies (installed on server via npm ci / Docker) --exclude='node_modules/' # Environment files (managed on server) --exclude='.env' --exclude='.env.*' # Server-managed data and uploads --exclude='data/' --exclude='media/' --exclude='files/' --exclude='videos/' --exclude='public/' # Local build / packaging artifacts --exclude='deploy-packages/' --exclude='output/' --exclude='tmp-*' --exclude='*.log' --exclude='*.tar.gz' --exclude='*.tgz' --exclude='*.dump' # Documentation (not needed for runtime) --exclude='docs/' --exclude='*.md' # Cloudflare deployment (not used in Docker deployment) --exclude='cloudflare/' --exclude='Dockerfile.cloudflare' --exclude='wrangler.jsonc' # Version control --exclude='.git/' --exclude='.github/' # Dev tools and editor configs --exclude='.claude/' --exclude='.playwright-cli/' --exclude='.tmp/' --exclude='.wrangler/' --exclude='.vercel/' --exclude='.vscode/' --exclude='.idea/' --exclude='.githooks/' # OS artifacts --exclude='.DS_Store' --exclude='._*' --exclude='__MACOSX/' # Raw seed data (not needed in production) --exclude='src/seed/raw/' # TLS certificates (managed on server) --exclude='nginx/certs/*.pem' --exclude='nginx/certs/*.key' ) # ── Minimal excludes for specific-path syncs ───────────────────── # When uploading a specific directory or file, only filter out # OS junk and dangerous directories. Project-level excludes like # *.md or docs/ do not apply. MINIMAL_EXCLUDES=( --exclude='.DS_Store' --exclude='._*' --exclude='__MACOSX/' --exclude='*.log' --exclude='node_modules/' --exclude='.next/' --exclude='.next-dev/' --exclude='nginx/certs/*.pem' --exclude='nginx/certs/*.key' ) # ── Build rsync flags ──────────────────────────────────────────── BASE_FLAGS=(-a -v -z) if [[ $DRY_RUN -eq 1 ]]; then BASE_FLAGS+=(--dry-run) fi # ── Summary header ─────────────────────────────────────────────── print_header() { local scope="$1" echo "==============================================" echo " Eversolo Web — rsync Deploy" echo "==============================================" echo " Target: ${DEPLOY_TARGET}:${REMOTE_DIR}" echo " SSH Port: ${SSH_PORT}" echo " Scope: ${scope}" echo " Mode: $([[ $DRY_RUN -eq 1 ]] && echo 'DRY RUN' || echo 'LIVE')" echo "==============================================" echo "" } # ── Full deploy ────────────────────────────────────────────────── full_deploy() { print_header "Full project" rsync "${BASE_FLAGS[@]}" --delete \ -e "$SSH_CMD" \ "${FULL_EXCLUDES[@]}" \ ./ "${DEPLOY_TARGET}:${REMOTE_DIR}/" } # ── Sync a single directory ────────────────────────────────────── sync_directory() { local dir="$1" # Ensure no leading ./ dir="${dir#./}" # Ensure trailing slash for rsync "contents" semantics dir="${dir%/}/" if [[ ! -d "$dir" ]]; then echo "ERROR: Directory not found: $dir" >&2 return 1 fi echo " → ${dir} → ${REMOTE_DIR}/${dir}" local flags=("${BASE_FLAGS[@]}") if [[ $FORCE_DELETE -eq 1 ]]; then flags+=(--delete) fi rsync "${flags[@]}" \ -e "$SSH_CMD" \ "${MINIMAL_EXCLUDES[@]}" \ "./${dir}" "${DEPLOY_TARGET}:${REMOTE_DIR}/${dir}" } # ── Sync one or more files ─────────────────────────────────────── sync_files() { # Use --relative so rsync preserves the full directory structure # e.g. ./src/payload/globals/Homepage.ts → /remote/src/payload/globals/Homepage.ts local file_args=() for f in "$@"; do f="${f#./}" if [[ ! -f "$f" ]]; then echo "ERROR: File not found: $f" >&2 return 1 fi echo " → ${f}" file_args+=("./${f}") done rsync "${BASE_FLAGS[@]}" --relative \ -e "$SSH_CMD" \ "${MINIMAL_EXCLUDES[@]}" \ "${file_args[@]}" \ "${DEPLOY_TARGET}:${REMOTE_DIR}/" } # ── Execute ───────────────────────────────────────────────────── if [[ ${#PATHS[@]} -eq 0 ]]; then # ── Full deploy ──────────────────────────────────────────────── full_deploy else # ── Specific paths: separate directories from files ─────────── DIRS=() FILES=() for p in "${PATHS[@]}"; do # Strip leading ./ p="${p#./}" if [[ -d "$p" ]]; then DIRS+=("$p") elif [[ -f "$p" ]]; then FILES+=("$p") else echo "ERROR: Path does not exist: $p" >&2 exit 1 fi done print_header "Specific paths (${#PATHS[@]} item(s))" # Sync directories if [[ ${#DIRS[@]} -gt 0 ]]; then for d in "${DIRS[@]}"; do sync_directory "$d" || exit 1 done fi # Sync files (batch with --relative) if [[ ${#FILES[@]} -gt 0 ]]; then sync_files "${FILES[@]}" || exit 1 fi fi # ── Post-deploy instructions ──────────────────────────────────── echo "" if [[ $DRY_RUN -eq 1 ]]; then echo "✓ Dry run complete — no files were transferred." echo " Run without --dry-run to apply changes." else echo "✓ Upload complete." if [[ ${#PATHS[@]} -eq 0 ]]; then echo "" echo "Next steps (run on the production server):" echo "" echo " 1. Run database migrations:" echo " cd ${REMOTE_DIR}" echo " docker compose run --rm --no-deps bootstrap bash -lc 'npm ci --include=dev && npm run payload:migrate && npm run payload:migrate:status'" echo "" echo " 2. Rebuild and restart the app:" echo " docker compose build app" echo " docker compose up -d app" fi fi