#!/usr/bin/env bash set -euo pipefail SLUG="${1:-}" if [[ -z "$SLUG" ]]; then echo "usage: deploy.sh " >&2 exit 1 fi ROOT="/var/www/sites/$SLUG" LOG="/var/log/websites/deploy-$SLUG.log" STATUS="/var/log/websites/deploy-$SLUG.status.json" ECOSYSTEM="/var/www/sites/ecosystem.config.cjs" exec >>"$LOG" 2>&1 write_status() { local status="$1" local detail="${2:-}" detail="${detail//\\/\\\\}" detail="${detail//\"/\\\"}" detail="${detail//$'\n'/ }" detail="${detail:0:500}" printf '{"slug":"%s","status":"%s","detail":"%s","at":"%s"}\n' \ "$SLUG" \ "$status" \ "$detail" \ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ >"$STATUS" } fail_snippet() { if [[ ! -f "$LOG" ]]; then return 0 fi local chunk snippet="" # Only the current deploy (log is appended across runs). chunk="$(awk 'BEGIN{s=""} /^==== / && /deploy start:/{s=""} {s=s $0 ORS} END{printf "%s", s}' "$LOG")" snippet="$( { printf '%s' "$chunk" | grep -E "Error:|Type error|Module not found|ELIFECYCLE|npm ERR!" || true; } \ | sed 's/\x1b\[[0-9;]*m//g' \ | tail -n 4 \ | tr '\n' ' ' \ | sed 's/[[:space:]]\+/ /g' )" if [[ -z "${snippet// }" ]]; then snippet="$( printf '%s' "$chunk" \ | tail -n 12 \ | sed 's/\x1b\[[0-9;]*m//g' \ | tr '\n' ' ' \ | sed 's/[[:space:]]\+/ /g' )" fi printf '%s' "$snippet" } echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ====" write_status "started" "Deploy running" cleanup_fail() { local code=$? trap - ERR local snippet snippet="$(fail_snippet)" if [[ -n "${snippet// }" ]]; then write_status "failed" "exit $code: $snippet" else write_status "failed" "deploy.sh exited with code $code" fi exit "$code" } trap cleanup_fail ERR if [[ ! -d "$ROOT/.git" ]]; then echo "missing site: $ROOT" write_status "failed" "missing site directory" exit 1 fi cd "$ROOT" export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -i /root/.ssh/websites_deploy -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new}" git fetch origin # Prefer origin/HEAD, then main, then master (repos may use either). BRANCH="" if git rev-parse --verify --quiet origin/HEAD >/dev/null; then BRANCH="$(git rev-parse --abbrev-ref origin/HEAD 2>/dev/null || true)" BRANCH="${BRANCH#origin/}" fi if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then if git rev-parse --verify --quiet origin/main >/dev/null; then BRANCH="main" elif git rev-parse --verify --quiet origin/master >/dev/null; then BRANCH="master" else echo "could not determine default branch (tried origin/HEAD, main, master)" write_status "failed" "no origin/main or origin/master" exit 1 fi fi echo "resetting to origin/$BRANCH" git checkout -B "$BRANCH" "origin/$BRANCH" git reset --hard "origin/$BRANCH" if [[ ! -f package.json ]]; then echo "package.json missing after checkout" write_status "failed" "package.json missing — empty or wrong branch?" exit 1 fi npm ci npm run build # Next.js standalone: copy static assets Next does not place in standalone/, then # point PM2 at server.js and drop the full node_modules tree. if [[ -f .next/standalone/server.js ]]; then echo "standalone build detected — preparing runtime bundle" mkdir -p .next/standalone/.next rm -rf .next/standalone/.next/static cp -a .next/static .next/standalone/.next/static if [[ -d public ]]; then rm -rf .next/standalone/public # Follow symlinks (e.g. public/images -> ../src/images) so standalone # serves real files. cp -a alone would copy a broken relative link. mkdir -p .next/standalone/public cp -aL public/. .next/standalone/public/ fi SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE' const fs = require('fs'); const path = process.env.ECOSYSTEM; const slug = process.env.SLUG; const root = process.env.ROOT; let cfg = { apps: [] }; try { delete require.cache[require.resolve(path)]; cfg = require(path); if (!Array.isArray(cfg.apps)) cfg.apps = []; } catch (_) { cfg = { apps: [] }; } const app = cfg.apps.find((a) => a.name === slug); if (!app) { console.error('ecosystem entry missing for', slug); process.exit(1); } const port = (app.env && Number(app.env.PORT)) || (String(app.args || '').match(/--port\s+(\d+)/) || [])[1] || 3005; app.cwd = root + '/.next/standalone'; app.script = 'server.js'; app.args = ''; app.env = { ...(app.env || {}), NODE_ENV: 'production', PORT: String(port), HOSTNAME: '127.0.0.1', }; const lines = ['module.exports = {', ' apps: [']; cfg.apps.forEach((entry, idx) => { lines.push(' {'); lines.push(` name: ${JSON.stringify(entry.name)},`); lines.push(` cwd: ${JSON.stringify(entry.cwd)},`); lines.push(` script: ${JSON.stringify(entry.script)},`); if (entry.args) { lines.push(` args: ${JSON.stringify(entry.args)},`); } lines.push(' env: {'); for (const [key, value] of Object.entries(entry.env || {})) { lines.push(` ${key}: ${JSON.stringify(String(value))},`); } lines.push(' },'); lines.push(` error_file: ${JSON.stringify(entry.error_file)},`); lines.push(` out_file: ${JSON.stringify(entry.out_file)},`); lines.push(' time: true,'); if (entry.max_memory_restart) { lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`); } lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },'); }); lines.push(' ],'); lines.push('};'); lines.push(''); fs.writeFileSync(path, lines.join('\n')); console.log('ecosystem updated for standalone:', slug, 'port', port); NODE # Full delete+start so PM2 picks up new cwd/script (restart alone keeps old path). pm2 delete "$SLUG" || true pm2 start "$ECOSYSTEM" --only "$SLUG" pm2 save || true echo "removing full node_modules (standalone runtime keeps its own minimal copy)" rm -rf node_modules # Drop Next build leftovers not needed at runtime (cache + non-standalone trees). # Keep .next/standalone only. if [[ -d .next ]]; then find .next -mindepth 1 -maxdepth 1 ! -name standalone -exec rm -rf {} + fi echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (standalone): $SLUG ====" write_status "success" "Deployed origin/$BRANCH (standalone)" trap - ERR exit 0 fi echo "no standalone output — using next start (legacy)" # Reset cwd/script in case a previous deploy used standalone. Do not # `pm2 restart --update-env`: the deploy agent has PORT=9050 and that # leaks into the site, so nginx (site port) gets 502. SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE' const fs = require('fs'); const path = process.env.ECOSYSTEM; const slug = process.env.SLUG; const root = process.env.ROOT; let cfg = { apps: [] }; try { delete require.cache[require.resolve(path)]; cfg = require(path); if (!Array.isArray(cfg.apps)) cfg.apps = []; } catch (_) { cfg = { apps: [] }; } const app = cfg.apps.find((a) => a.name === slug); if (!app) { console.error('ecosystem entry missing for', slug); process.exit(1); } const fromEnv = Number(app.env && app.env.PORT); const fromArgs = Number((String(app.args || '').match(/--port\s+(\d+)/) || [])[1]); const port = [fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005; app.cwd = root; app.script = 'node_modules/next/dist/bin/next'; app.args = 'start --hostname 127.0.0.1 --port ' + port; app.env = { NODE_ENV: 'production', PORT: String(port), }; const lines = ['module.exports = {', ' apps: [']; cfg.apps.forEach((entry, idx) => { lines.push(' {'); lines.push(` name: ${JSON.stringify(entry.name)},`); lines.push(` cwd: ${JSON.stringify(entry.cwd)},`); lines.push(` script: ${JSON.stringify(entry.script)},`); if (entry.args) { lines.push(` args: ${JSON.stringify(entry.args)},`); } lines.push(' env: {'); for (const [key, value] of Object.entries(entry.env || {})) { lines.push(` ${key}: ${JSON.stringify(String(value))},`); } lines.push(' },'); lines.push(` error_file: ${JSON.stringify(entry.error_file)},`); lines.push(` out_file: ${JSON.stringify(entry.out_file)},`); lines.push(' time: true,'); if (entry.max_memory_restart) { lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`); } lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },'); }); lines.push(' ],'); lines.push('};'); lines.push(''); fs.writeFileSync(path, lines.join('\n')); console.log('ecosystem updated for next start:', slug, 'port', port); NODE pm2 delete "$SLUG" || true pm2 start "$ECOSYSTEM" --only "$SLUG" pm2 save || true echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ====" write_status "success" "Deployed origin/$BRANCH" trap - ERR