#!/usr/bin/env bash set -euo pipefail # Production is the only deployed environment (local dev uses scripts/db-refresh-dev.sh). # deploy-remote.sh deploy back up, migrate, seed, preflight, switch, health-check # deploy-remote.sh rollback back up, preflight, switch to an earlier image (no migrations; # the database is not rolled back, restore a backup for that) sha=${1:?commit SHA required} mode=${2:-deploy} case "$sha" in *[!0-9a-f]*|'') echo 'Invalid image tag' >&2; exit 2 ;; esac case "$mode" in deploy|rollback) ;; *) echo 'Mode must be deploy or rollback' >&2; exit 2 ;; esac directory=/opt/personal-website origin=https://stanrunge.dev environment=/etc/personal-website/prod.env environment_name=production image="git.stanrunge.dev/stan/website:$sha" compose="$directory/compose.prod.yml" compose_files=(-f "$compose") # Images built before the repo rename (stan/personal-website -> stan/website) keep # their old local name; retag so rollbacks to those SHAs still work. legacy_image="git.stanrunge.dev/stan/personal-website:$sha" if ! sudo docker image inspect "$image" >/dev/null 2>&1 && sudo docker image inspect "$legacy_image" >/dev/null 2>&1; then sudo docker tag "$legacy_image" "$image" fi if ! sudo docker image inspect "$image" >/dev/null 2>&1; then echo "Image $image is not on this host" >&2; exit 1 fi # Book files (ebooks + covers) live on the host and are bind-mounted read-only at /books; # the app streams them only to the owner via /trackers/books/files/ (no static path). # Layout: $books_host_dir/library/ and $books_host_dir/covers/. # compose.prod.yml is host-managed, so the mount goes in a generated override file. books_host_dir=${BOOKS_HOST_DIR:-/home/stan/books} case "$books_host_dir" in /*) ;; *) echo 'BOOKS_HOST_DIR must be absolute' >&2; exit 2 ;; esac # The deploy account can't traverse /home/stan (0750); check from a root container instead. if sudo docker run --rm --entrypoint sh \ --mount "type=bind,source=$books_host_dir,target=/books,readonly" "$image" \ -c 'test -d /books/library' 2>/dev/null; then sudo tee "$directory/compose.books.yml" >/dev/null <&2 fi previous_tag=$(sed -n 's/^IMAGE_TAG=//p' "$directory/.env" | head -n 1) switched=0 rollback() { if [ "$switched" -eq 1 ] && [ -n "$previous_tag" ]; then echo "Health check failed; restoring prior image tag $previous_tag" >&2 printf 'IMAGE_TAG=%s\n' "$previous_tag" | sudo tee "$directory/.env" >/dev/null sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d fi } trap rollback ERR # The deploy account cannot read the env file directly; check inside a container. if ! sudo docker run --rm --env-file "$environment" --entrypoint sh "$image" \ -c 'test -n "${DB_MIGRATE_URL:-}"'; then echo "DB_MIGRATE_URL (schema owner) is missing from $environment" >&2; exit 1 fi db_version=$(sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun scripts/print-db-server-version.ts) case "$db_version" in *[!0-9]*|'') echo 'Could not determine the PostgreSQL server version' >&2; exit 1 ;; esac db_major=$((db_version / 10000)) if [ "$db_major" -gt 17 ]; then echo "Server is PostgreSQL $db_major; update the pinned postgres:17-alpine backup image" >&2; exit 1 fi backup_image=postgres:17-alpine backup_dir=/var/backups/personal-website backup_stamp=$(date -u +%Y%m%dT%H%M%SZ) backup_name="${environment_name}-${backup_stamp}-${sha}.dump" sudo docker pull "$backup_image" # The deploy account can run Docker, but has no general host-file sudo access. # A bind volume creates the root-owned directory; restrict it before dumping. sudo docker run --rm --network backend --env-file "$environment" \ --volume "$backup_dir:/backups" \ --env "BACKUP_FILE=$backup_name" "$backup_image" \ sh -eu -c 'chmod 0700 /backups; umask 077; pg_dump --format=custom --no-owner --no-acl --file="/backups/$BACKUP_FILE" "$DB_MIGRATE_URL"' sudo docker run --rm --mount "type=bind,source=$backup_dir,target=/backups" \ "$backup_image" pg_restore --list "/backups/$backup_name" >/dev/null echo "Verified $environment_name database backup at $backup_dir/$backup_name" # Migrations and seeds (ref.* is read-only for the app) need the owner role. # DB_MIGRATE_URL (role personal) must be set in the env file; DB_URL is the app # role (personal_app), which has no DDL rights. run_database_script() { echo "Running $1" sudo docker run --rm --network backend --env-file "$environment" \ --entrypoint sh "$image" -eu -c 'DB_URL="$DB_MIGRATE_URL" exec bun "$0"' "$1" } if [ "$mode" = deploy ]; then # Schema changes: Drizzle migrations from ./drizzle, as the schema owner. run_database_script scripts/db-migrate.ts # Refresh the generic table GUI's column registry (meta.columns) for tables that migrations # registered in meta.tables. meta.sync() is SECURITY DEFINER and granted to the app role, not # to the schema owner, so run it as DB_URL (personal_app). echo "Running meta.sync()" sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun scripts/db-meta-sync.ts run_database_script scripts/seed-catalog.ts run_database_script scripts/seed-cod-catalog.ts run_database_script scripts/seed-pokemon-research.ts run_database_script scripts/seed-cod-challenges.ts run_database_script scripts/seed-cod-art.ts fi sudo docker run --rm --network backend --env-file "$environment" "$image" \ bun scripts/deploy-preflight.ts "$origin" printf 'IMAGE_TAG=%s\n' "$sha" | sudo tee "$directory/.env" >/dev/null switched=1 sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" up -d container=$(sudo docker compose --env-file "$directory/.env" "${compose_files[@]}" ps -q app) expected_image=$(sudo docker image inspect -f '{{.Id}}' "$image") actual_image=$(sudo docker inspect -f '{{.Image}}' "$container") if [ "$actual_image" != "$expected_image" ]; then echo 'Compose did not start the intended image' >&2 false fi curl --retry 12 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ "$origin/login" >/dev/null curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ "$origin/projects/game-tracker/pokemon" >/dev/null curl --retry 6 --retry-delay 2 --retry-all-errors --fail --silent --show-error \ "$origin/projects/game-tracker/call-of-duty/black-ops-2" >/dev/null trap - ERR echo "Deployed $sha to $origin ($mode)"