#!/bin/bash # Deploy a branch on the VPS. Default is `main` — the stable/production # branch (only tested, reviewed code lands there; production deploys from # it). Active development happens on feat/go-backend; pass a branch name # explicitly to deploy a dev build. # # Usage: # ./scripts/deploy.sh # pulls main (production) # ./scripts/deploy.sh feat/go-backend # pulls the dev branch # ./scripts/deploy.sh some/other-branch # pulls some/other-branch # # Pre-flight (one-time on a fresh VPS): # - /opt/animego cloned from git@github.com:lawrenceli0228/animego.git # - .env.production + nginx/selfsigned.* present (gitignored, copied # manually from local + chmod 600) # - docker + docker compose installed set -e APP_DIR="/opt/animego" BRANCH="${1:-main}" cd "$APP_DIR" echo "==> Pulling latest code from origin/$BRANCH..." git fetch origin "$BRANCH" git checkout "$BRANCH" git reset --hard "origin/$BRANCH" # FOOTGUN GUARD: this shell still holds the PRE-pull deploy.sh in memory, so # any steps the pull ADDED below (migrate, nginx cp) would be silently skipped # — exactly how the first P11 deploy left /api on legacy Express + the DB # un-migrated. Re-exec the freshly-pulled script once so the new steps run. # DEPLOY_REEXEC guards against an infinite loop. if [ -z "${DEPLOY_REEXEC:-}" ]; then echo "==> Re-exec'ing freshly pulled deploy.sh..." exec env DEPLOY_REEXEC=1 bash "$APP_DIR/scripts/deploy.sh" "$@" fi # --env-file=.env.production: # - feeds `${VAR}` substitutions in docker-compose.yml (e.g. # NEXT_PUBLIC_SENTRY_DSN passed as a build arg into Dockerfile so # Next.js can inline it into the client bundle at build time). # - also overrides the default `.env` lookup so service `env_file:` # references stay consistent across build + runtime. COMPOSE="docker compose --env-file=.env.production" # git reset --hard above reverts nginx/default.conf to the committed (legacy) # version. Restore the P9 routing config (/api -> go-api, /socket.io -> # ws-server) BEFORE the `restart nginx` below, or /api silently drops back # onto the legacy Express `app` service (mismatched ObjectId vs uuid logs # users out). See memory feedback_deploy_nginx_bind_mount_restart + # project_dns_rollback. echo "==> Restoring P9 nginx routing (default.p9.conf -> default.conf)..." cp nginx/default.p9.conf nginx/default.conf # Apply DB migrations BEFORE recreating go-api. The new Go binary + River # queue reference columns/tables added by go-api/migrations/* (e.g. P11's # bgm_match_source / bgm_id_map); bringing go-api up against an un-migrated # DB 500s (GetAdminStats) or crash-loops the queue. Postgres is already up; # the migrate profile applies the chain idempotently (no-op when current). echo "==> Applying DB migrations..." $COMPOSE --profile migrate run --rm migrate echo "==> Building Docker images..." # GIT_SHA becomes NEXT_PUBLIC_BUILD_ID inside the next-app image, which is how # a browser tab opened before this deploy discovers it is running code the # server no longer serves (StaleTabNotice). Exported rather than passed inline # so compose picks it up as the build arg declared in docker-compose.yml. # # It falls back to a timestamp inside next.config.ts when unset, so a build # outside this script still gets a distinct id -- the only thing the SHA buys # is that redeploying the same commit does not read as a new version and ask # every reader to refresh for nothing. GIT_SHA="$(git rev-parse --short=12 HEAD 2>/dev/null || true)" export GIT_SHA # BuildKit attaches a provenance attestation, stamped with the build time, to # every image it builds, so even a build whose every layer was cached produced # a new image ID, and `up -d` then recreated every service with a build: # section. Postgres was one of them: the database restarted on every deploy, # and go-api, recreated right after it, shut down with no database to record # its running jobs in, which left the image warm pass stranded as 'running' # for an hour (queue.Shutdown). These images never leave this machine, so the # attestation buys nothing. Without it an unchanged service keeps its image ID, # and its container keeps running. export BUILDX_NO_DEFAULT_ATTESTATIONS=1 echo " build id: ${GIT_SHA:-}" $COMPOSE build echo "==> Bringing services up..." $COMPOSE up -d # nginx config is a bind-mount (./nginx/default.conf → # /etc/nginx/conf.d/default.conf). After `git pull`/reset the inode # changes, and `nginx -s reload` reads the stale fd. `restart` re-opens # the file. See memory feedback_deploy_nginx_bind_mount_restart. echo "==> Restarting nginx to pick up bind-mounted conf changes..." $COMPOSE restart nginx echo "==> Status:" $COMPOSE ps # The AniList originals cache may grow to 30 GB on this disk, next to the # database and docker's build cache, which grows back on its own. The warm job # stops storing and reports to Sentry below 10 GB free; this line shows the # margin at every deploy. echo "==> Disk:" df -h / echo "==> Smoke (via nginx, -k for self-signed cert)..." curl -sk -o /dev/null -w "HTTP %{http_code} from /api/health\n" https://localhost/api/health curl -sk -o /dev/null -w "HTTP %{http_code} from /\n" https://localhost/ curl -sk -o /dev/null -w "HTTP %{http_code} from /anime/154587\n" https://localhost/anime/154587 # /_next/image is served by the next-image container, not next-app. curl -sk -o /dev/null -w "HTTP %{http_code} from /_next/image (next-image)\n" "https://localhost/_next/image?url=%2Fcommunity-guide.jpg&w=640&q=85" # The AniList mirror; each line should say 200 image/* (or OK). First, nginx # serving an original: from its store once the warm job has run, from # AniList until then. Second, a /_next/image request shaped like a page's, # whose source is the mirror. MIRROR_PATH="media/anime/cover/medium/bx154587-qQTzQnEJJ3oB.jpg" curl -sk -o /dev/null -w "HTTP %{http_code} %{content_type} from /img/anilist/ (mirror original)\n" "https://localhost/img/anilist/$MIRROR_PATH" curl -sk -o /dev/null -w "HTTP %{http_code} %{content_type} from /_next/image (mirror source)\n" "https://localhost/_next/image?url=https%3A%2F%2Fanimegoclub.com%2Fimg%2Fanilist%2F${MIRROR_PATH//\//%2F}&w=640&q=85" # The /_next/image line above is answered from nginx's variant cache once it # has passed, so it stops proving the optimizer can still reach the mirror. # This one cannot be cached in front: from inside next-image, the request Next # makes for an image it has not encoded yet (same runtime, DNS and route # through the CDN). Anything but 200 image/* means new images cannot load. $COMPOSE exec -T next-image node -e ' const url = process.argv[1]; fetch(url, { signal: AbortSignal.timeout(7000), redirect: "manual" }) .then(async (r) => { console.log(`HTTP ${r.status} ${r.headers.get("content-type")} from next-image -> ${url}`); await r.body?.cancel(); }) .catch((e) => console.log(`FAILED from next-image -> ${url}: ${e.name}: ${e.message}`)); ' "https://animegoclub.com/img/anilist/$MIRROR_PATH" || true # The warm job's own way in: from the go-api container, through the # IMAGE_WARM_BASE_URL it reads. OK means it can store originals; a 403 is a # wrong path or allowlists that disagree; no answer is a wrong host or port. $COMPOSE exec -T go-api sh -c ' if [ -z "$IMAGE_WARM_BASE_URL" ]; then echo "warm job off (IMAGE_WARM_BASE_URL is empty)" elif out=$(wget -nv -O /dev/null "${IMAGE_WARM_BASE_URL%/}/$1" 2>&1); then echo "OK from the warm endpoint (go-api -> $IMAGE_WARM_BASE_URL)" else echo "FAILED from the warm endpoint (go-api -> $IMAGE_WARM_BASE_URL): $out" fi' sh "$MIRROR_PATH" || true echo "" echo "==> Done. If a smoke line shows 5xx, check 'docker compose logs --tail=50 '."