From a2bcb2f0c82f018c9829de01d7db7860956c12e7 Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Wed, 15 Jul 2026 13:02:11 +0200 Subject: [PATCH] Add a one-command SSH deploy for the dev server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deploy/deploy-dev.sh deploys the test instance to home.siebens.org behind its existing Caddy: from your machine, over one SSH session, it fetches the pushed branch, builds, migrates explicitly, restarts web, and waits for /healthz. - A hard reset to origin/, not a pull: a deploy target only receives deploys, so it should match the branch exactly rather than risk a merge conflict from drift no one meant to leave on the server. - Refuses to deploy a branch with unpushed local commits — the server pulls from git, so that would ship stale code without saying so. - Migrations run explicitly (dc run --rm web migrate), never from the entrypoint, and only `web` is recreated so db/redis keep running. - Fails loudly if .env.production or .env is missing rather than booting a half-configured stack, and dumps recent web logs if the health check never passes. Host/user/dir/branch all override via env vars. Documented in DEPLOYMENT.md with the first-time server setup. Co-Authored-By: Claude Opus 4.8 --- DEPLOYMENT.md | 29 +++++++++++++ deploy/deploy-dev.sh | 98 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100755 deploy/deploy-dev.sh diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index d34c184..eda23d4 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -298,6 +298,35 @@ A *.test.rosterchief.app -> The compose project is named `rosterchief-test`, so its containers and volumes never collide with a production stack on the same host. +### Deploying with one command + +Once the server has the repo cloned at `/home/bernard/RosterChief` and its two env files in +place, `deploy/deploy-dev.sh` does a full deploy over SSH: + +```bash +deploy/deploy-dev.sh # deploy the current branch +BRANCH=main deploy/deploy-dev.sh +deploy/deploy-dev.sh --push # push the branch first, then deploy +``` + +It runs from your machine and does the work on the server in one SSH session: fetch the pushed +branch (a hard reset to `origin/`, since a deploy target only receives deploys), build +the image, run migrations *explicitly*, restart only `web`, and wait for `/healthz`. + +It refuses to deploy a branch whose local commits are not pushed — the server pulls from git, +so unpushed work would ship stale code silently. Override the host, user, directory or branch +with the `SSH_HOST` / `SSH_USER` / `REMOTE_DIR` / `BRANCH` environment variables. + +First-time setup on the server, once: + +```bash +git clone git@git.siebens.org:bernard/RosterChief.git /home/bernard/RosterChief +cd /home/bernard/RosterChief +cp .env.compose.example .env # fill in POSTGRES_PASSWORD etc. +cp .env.production.example .env.production +# then add the reverse_proxy site block to the host's Caddy (see above) +``` + ## Automated backups `deploy/backup.sh` dumps the database, tars the uploads while they are still on local disk, diff --git a/deploy/deploy-dev.sh b/deploy/deploy-dev.sh new file mode 100755 index 0000000..d56baf7 --- /dev/null +++ b/deploy/deploy-dev.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# Deploy the test instance to the dev server, behind its existing Caddy. +# +# deploy/deploy-dev.sh # deploy the current branch +# BRANCH=main deploy/deploy-dev.sh +# deploy/deploy-dev.sh --push # push the branch first, then deploy +# +# Runs FROM your machine, works ON the server over one SSH session: it fetches the pushed +# branch, builds the image, runs migrations explicitly (never from the entrypoint — a +# starting gunicorn worker is a bad place to discover a failed migration), restarts web, and +# waits for /healthz. Any step failing aborts the whole thing with a non-zero exit. +set -Eeuo pipefail + +# --- config (override via env) ---------------------------------------------- +SSH_HOST="${SSH_HOST:-home.siebens.org}" +SSH_USER="${SSH_USER:-bernard}" +REMOTE_DIR="${REMOTE_DIR:-/home/bernard/RosterChief}" +BRANCH="${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}" +COMPOSE_FILE="${COMPOSE_FILE:-compose.behind-proxy.yaml}" +HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:8001/healthz}" + +SSH_TARGET="${SSH_USER}@${SSH_HOST}" + +say() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } +die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } + +# --- preflight, locally ----------------------------------------------------- +# The server deploys what is on the git remote, so unpushed commits would silently ship stale +# code. Catch that here rather than after a confusing "why isn't my change live" round trip. +git rev-parse --verify --quiet "origin/${BRANCH}" >/dev/null \ + || die "origin/${BRANCH} does not exist. Push the branch first, or pass --push." + +if [ "${1:-}" = "--push" ]; then + say "Pushing ${BRANCH} to origin" + git push origin "${BRANCH}" +elif [ -n "$(git rev-list "origin/${BRANCH}..HEAD" 2>/dev/null)" ]; then + die "Local ${BRANCH} is ahead of origin — the server would deploy stale code. Push first, or run with --push." +fi + +say "Deploying ${BRANCH} to ${SSH_TARGET}:${REMOTE_DIR}" + +# --- the work, on the server ------------------------------------------------ +# One SSH session runs the whole remote script; args are passed positionally so nothing has to +# be re-quoted inside the heredoc. +ssh -o ConnectTimeout=10 "${SSH_TARGET}" bash -s -- "${REMOTE_DIR}" "${BRANCH}" "${COMPOSE_FILE}" "${HEALTH_URL}" <<'REMOTE' +set -Eeuo pipefail +REMOTE_DIR="$1"; BRANCH="$2"; COMPOSE_FILE="$3"; HEALTH_URL="$4" + +step() { printf '\033[1;34m ->\033[0m %s\n' "$*"; } + +cd "$REMOTE_DIR" 2>/dev/null || { echo "ERROR: $REMOTE_DIR not found. Clone the repo there first."; exit 1; } +[ -d .git ] || { echo "ERROR: $REMOTE_DIR is not a git checkout."; exit 1; } + +# The env files carry secrets and are never committed, so they must already be on the server. +# Fail loudly rather than boot a half-configured stack. +[ -f .env.production ] || { echo "ERROR: .env.production missing (Django config). Copy from .env.production.example."; exit 1; } +[ -f .env ] || { echo "ERROR: .env missing (compose vars: POSTGRES_PASSWORD, ...). Copy from .env.compose.example."; exit 1; } + +dc() { docker compose -f "$COMPOSE_FILE" "$@"; } + +# reset --hard, not pull: a deploy target only receives deploys, so make it exactly match the +# remote branch rather than risk a merge conflict from drift no one meant to leave there. +step "Fetching ${BRANCH}" +git fetch --quiet origin +git checkout --quiet "$BRANCH" +git reset --hard --quiet "origin/${BRANCH}" +echo " at $(git rev-parse --short HEAD) — $(git log -1 --pretty=%s)" + +step "Building image" +dc build + +# Bring the data services up first and wait for Postgres, so the migration below has something +# to connect to on a cold start. +step "Starting db + redis" +dc up -d db redis + +step "Running migrations" +dc run --rm web python manage.py migrate --noinput + +# Recreate only web, with the freshly built image. db and redis keep running untouched. +step "Restarting web" +dc up -d --no-deps web + +step "Waiting for /healthz" +for attempt in $(seq 1 20); do + if curl -fsS "$HEALTH_URL" >/dev/null 2>&1; then + echo " healthy after ${attempt} check(s)" + exit 0 + fi + sleep 3 +done + +echo "ERROR: health check never passed. Recent web logs:" +dc logs --tail 40 web +exit 1 +REMOTE + +say "Done. https:///healthz should return ok."