#!/usr/bin/env bash # Deploy to the VPS. # # pnpm run deploy build here, ship the image, restart, verify # pnpm run deploy --dry-run show what would change on the server, touch nothing # pnpm run deploy --yes skip the confirmation # pnpm run deploy --server-build build on the VPS instead (the old behaviour) # pnpm run deploy --no-build copy the source only, leave the containers alone # # The image is built on this machine and loaded into the server's docker, which # never compiles anything. A laptop builds the Go binary and the Vite bundle in # a fraction of the time a small droplet takes, and a build that fails fails # here, before the running site has been touched. # # What crosses the wire: the source tree by rsync (compose.yaml and the # Dockerfile have to be there for `--server-build` and for reading), and the # built image by `docker save | gzip | docker load` over the same ssh # connection. Layers docker already has are still re-sent — this is a save, not # a registry push — so expect tens of megabytes per deploy. # # Two things this script deliberately does not do: # # * It never writes .env. Production secrets live on the server and nowhere # else; the local .env holds the development ones and would silently # downgrade the deployment if it were copied. rsync --delete cannot remove # the remote .env either — excluded files are protected from deletion. # * It never touches the database. Schema changes are applied by the `migrate` # service, which compose runs before `app` starts, from this same image. set -euo pipefail cd "$(dirname "$0")/.." HOST=attila_personal REMOTE_DIR=/home/attila/Projects/szabadpalya IMAGE=szabadpalya DRY_RUN=0 ASSUME_YES=0 BUILD=1 SERVER_BUILD=0 for arg in "$@"; do case "$arg" in --dry-run|-n) DRY_RUN=1 ;; --yes|-y) ASSUME_YES=1 ;; --no-build) BUILD=0 ;; --server-build) SERVER_BUILD=1 ;; *) echo "usage: $0 [--dry-run] [--yes] [--no-build] [--server-build]"; exit 2 ;; esac done bold() { printf '\033[1m%s\033[0m\n' "$1"; } info() { printf ' %s\n' "$1"; } warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } die() { printf ' \033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } command -v ssh >/dev/null || die "ssh is missing" command -v rsync >/dev/null || die "rsync is missing" command -v docker >/dev/null || die "docker is missing — needed to build the image here" [ -f compose.yaml ] || die "run this from the repository — compose.yaml not found" # A tag per revision, so a bad deploy can be rolled back on the server with a # retag rather than a rebuild. Both tags name one image; `docker save` of the # pair sends the layers once. if git rev-parse --git-dir >/dev/null 2>&1; then TAG=$(git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d-%H%M%S) [ -n "$(git status --porcelain)" ] && TAG="$TAG-dirty" else TAG=$(date +%Y%m%d-%H%M%S) fi # ------------------------------------------------------------ connection ---- # One multiplexed connection for the whole deploy. The deploy key is # passphrase-protected and there is no agent, so without this the passphrase # would be asked for once per ssh, once for rsync and once for the image. CTL_DIR=$(mktemp -d) CTL="$CTL_DIR/ssh.sock" SSH_OPTS=(-o ControlPath="$CTL") cleanup() { ssh -O exit "${SSH_OPTS[@]}" "$HOST" 2>/dev/null || true rm -rf "$CTL_DIR" } trap cleanup EXIT bold "Connecting to $HOST" ssh -o ControlMaster=yes -o ControlPersist=600 -o ConnectTimeout=15 \ "${SSH_OPTS[@]}" -fN "$HOST" || die "could not open an ssh connection to $HOST" remote() { ssh "${SSH_OPTS[@]}" "$HOST" "$@"; } ok "connected" # -------------------------------------------------------------- preflight ---- bold "Preflight" remote 'command -v docker >/dev/null' || die "docker is not installed on $HOST" remote 'docker compose version >/dev/null 2>&1' \ || die "the docker compose plugin is missing on $HOST" remote 'command -v rsync >/dev/null' \ || die "rsync is not installed on $HOST — sudo apt install rsync" ok "docker, compose and rsync present" # An image built here only runs there if the architectures match. Nothing in # the build declares a platform, so this is the check that would otherwise # surface as "exec format error" in the app logs after a successful deploy. if [ "$SERVER_BUILD" = 0 ] && [ "$BUILD" = 1 ]; then LOCAL_ARCH=$(docker info --format '{{.Architecture}}' 2>/dev/null || uname -m) REMOTE_ARCH=$(remote 'uname -m') [ "$LOCAL_ARCH" = "$REMOTE_ARCH" ] \ || die "architecture mismatch: this machine is $LOCAL_ARCH, $HOST is $REMOTE_ARCH — build there instead with --server-build" ok "architecture $LOCAL_ARCH on both ends" fi remote "mkdir -p $REMOTE_DIR" # The server refuses to boot without SESSION_SECRET, and a missing .env would # also hand compose the dev-default Postgres password. Stop here rather than # half-deploy: nothing has been copied yet. if ! remote "test -f $REMOTE_DIR/.env"; then printf '\n \033[31m✗\033[0m no .env on %s at %s\n\n' "$HOST" "$REMOTE_DIR" # Quoted heredoc, then substituted: the $(openssl ...) below must reach the # screen literally so that it runs on the server when pasted. Generating the # secret here would put it in this terminal's scrollback instead. sed -e "s|@HOST@|$HOST|g" -e "s|@DIR@|$REMOTE_DIR|g" <<'EOF' Create it there once. It is the only copy of these secrets, and neither this script nor rsync --delete will ever overwrite or remove it: ssh @HOST@ mkdir -p @DIR@ && cd @DIR@ umask 077 { echo "BASE_URL=https://your.real.domain" echo "SESSION_SECRET=$(openssl rand -base64 32)" echo "POSTGRES_PASSWORD=$(openssl rand -hex 24)" echo "ADMIN_EMAILS=mail@attilak.com" } > .env BASE_URL must be the real external origin — it decides whether the session cookie is marked Secure and what host magic links point at. DATABASE_URL is not needed: compose builds it from POSTGRES_USER/PASSWORD/DB for the app. The password is hex, not base64, on purpose. compose interpolates it into postgresql://user:PASSWORD@db:5432/db, and base64's "/" would end the host there — a password that fails to parse about two times in five. EOF exit 1 fi remote "grep -qE '^SESSION_SECRET=.+' $REMOTE_DIR/.env" \ || die "SESSION_SECRET is empty in the remote .env — the server will refuse to start" REMOTE_BASE_URL=$(remote "grep -E '^BASE_URL=' $REMOTE_DIR/.env | head -1 | cut -d= -f2-" || true) case "$REMOTE_BASE_URL" in https://*) ok "BASE_URL=$REMOTE_BASE_URL" ;; "") warn "BASE_URL is not set in the remote .env — it defaults to http://localhost:3000" ;; *) warn "BASE_URL=$REMOTE_BASE_URL is not https — the session cookie will not be Secure" ;; esac remote "grep -qE '^POSTGRES_PASSWORD=.+' $REMOTE_DIR/.env" \ || warn "POSTGRES_PASSWORD is not set — compose falls back to the dev default (5433 is loopback-only, so this is defence in depth, not an exposure)" case "$TAG" in *-dirty) warn "deploying $TAG — the working tree has uncommitted changes" ;; *) ok "deploying $TAG" ;; esac # ------------------------------------------------------------------ copy ---- # --include before the --excludes: rsync takes the first pattern that matches. # Everything listed here is either rebuilt on the server (dist, node_modules), # secret (.env), local-only noise (caches, logs) or internal material that has # no business on a web server (brand/). RSYNC_FILTER=( --include '.env.example' --exclude '.git/' --exclude '.githooks/' --exclude 'node_modules/' --exclude 'web/dist/' --exclude '.env' --exclude '.env.*' --exclude 'brand/' --exclude 'api/tmp/' --exclude 'coverage/' --exclude '*.tsbuildinfo' --exclude '*.log' --exclude '.DS_Store' ) RSYNC_OPTS=(-az --delete --human-readable "${RSYNC_FILTER[@]}" -e "ssh -o ControlPath=$CTL") if [ "$DRY_RUN" = 1 ]; then bold "Dry run — what would change on $HOST:$REMOTE_DIR" rsync "${RSYNC_OPTS[@]}" --dry-run --itemize-changes ./ "$HOST:$REMOTE_DIR/" echo info "nothing was built, copied or restarted" exit 0 fi bold "About to deploy" info "from $PWD" info "to $HOST:$REMOTE_DIR" if [ "$BUILD" = 0 ]; then info "build none — --no-build, the running containers keep serving the old image" elif [ "$SERVER_BUILD" = 1 ]; then info "build on $HOST (docker compose up -d --build)" else info "build here, shipped as $IMAGE:$TAG" fi if [ "$ASSUME_YES" != 1 ]; then printf '\n Continue? [y/N] ' read -r reply /dev/null" || true) if [ "$LOCAL_ID" = "$REMOTE_ID" ]; then ok "$HOST already has this exact image — nothing to send" else # gzip -1: the wire is slower than the CPU on both ends, but only barely, # and the image is mostly an already-compressed binary. docker save "$IMAGE:$TAG" "$IMAGE:latest" \ | gzip -1 \ | remote "gunzip | docker load" \ || die "could not load the image on $HOST" ok "image loaded on $HOST" fi fi # --------------------------------------------------------------- restart ---- # migrate is a dependency of app, so compose runs the migrations from this same # image and waits for them to succeed before the new app container takes over. bold "Restarting on $HOST" if [ "$SERVER_BUILD" = 1 ]; then COMPOSE_CMD="docker compose up -d --build" else COMPOSE_CMD="docker compose up -d" fi remote "cd $REMOTE_DIR && $COMPOSE_CMD" \ || die "compose failed — ssh $HOST 'cd $REMOTE_DIR && docker compose logs --tail 50'" # ----------------------------------------------------------------- verify ---- bold "Verifying" for i in $(seq 1 30); do if remote "curl -fsS -o /dev/null http://127.0.0.1:3000/api/health"; then ok "/api/health is answering on the server" break fi if [ "$i" = 30 ]; then # Print what the server knows before giving up. A refused connection means # nothing is listening, which is a container that exited rather than a slow # boot — and the reason for that is only ever in its logs. printf '\n' warn "the app never answered; here is what $HOST says" remote "cd $REMOTE_DIR && docker compose ps -a --format ' {{.Service}}\t{{.Status}}\t{{.Image}}'" || true printf '\n' remote "cd $REMOTE_DIR && docker compose logs --tail 30 --no-color app" 2>&1 | sed 's/^/ /' || true die "deploy did not come up — the containers were left as they are" fi sleep 2 done remote "cd $REMOTE_DIR && docker compose ps --format ' {{.Service}}\t{{.Status}}'" || true # Keep the three most recent releases and drop the rest. `docker image prune` # is no use here: every release carries a tag, so it finds nothing dangling and # reclaims nothing, and `prune -a` would reach for images this project does not # own. Untagging by name is the only cleanup that is both effective and narrow. remote "docker images --filter reference=$IMAGE --format '{{.Tag}}' \ | grep -vx latest | tail -n +4 \ | xargs -r -I% docker rmi $IMAGE:% >/dev/null 2>&1 docker image prune -f >/dev/null 2>&1" || true echo ok "deployed $TAG${REMOTE_BASE_URL:+ — $REMOTE_BASE_URL}" info "roll back: ssh $HOST \"cd $REMOTE_DIR && docker tag $IMAGE: $IMAGE:latest && docker compose up -d\""