Deploy script

This commit is contained in:
Kovács Attila 2026-09-20 00:43:35 -07:00
parent 6aeb1f2344
commit 7df4c18ccd
7 changed files with 346 additions and 12 deletions

View File

@ -44,12 +44,6 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["server"]
# Migrations run from the same image and the same code path the server uses,
# so the two can never disagree about what "migrated" means.
FROM runner AS migrator
USER app
CMD ["server", "-migrate"]
# ---------------------------------------------------------------- seeder ----
# The fixture loader, for development and CI only. Its first act is to DELETE
# every campaign, organisation and user, so it must not be reachable from the

View File

@ -14,8 +14,12 @@ services:
POSTGRES_USER: ${POSTGRES_USER:-szabadpalya}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-szabadpalya}
POSTGRES_DB: ${POSTGRES_DB:-szabadpalya}
# Loopback only. Docker publishes ports by writing DNAT rules that bypass
# UFW entirely, so a bare "5433:5432" would put Postgres on the public
# internet no matter what the host firewall says. Development still reaches
# it on localhost:5433, which is all scripts/db.sh ever wanted.
ports:
- "5433:5432"
- "127.0.0.1:5433:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
@ -25,10 +29,17 @@ services:
retries: 10
# Runs once, migrates, exits. The app starts only after it succeeds.
#
# Same image as app, same binary, one flag different — so the two can never
# disagree about what "migrated" means. That used to be a separate Dockerfile
# stage; a command override says it with less machinery and one less image to
# ship.
migrate:
image: szabadpalya:latest
command: ["server", "-migrate"]
build:
context: .
target: migrator
target: runner
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-szabadpalya}:${POSTGRES_PASSWORD:-szabadpalya}@db:5432/${POSTGRES_DB:-szabadpalya}
SESSION_SECRET: ${SESSION_SECRET:-}
@ -37,7 +48,11 @@ services:
condition: service_healthy
restart: "no"
# `image:` is what lets scripts/deploy.sh build on a laptop and ship the
# result: compose uses an image of this name when one is already present and
# only falls back to `build:` when it is not, or when asked with --build.
app:
image: szabadpalya:latest
build:
context: .
target: runner
@ -46,8 +61,12 @@ services:
SESSION_SECRET: ${SESSION_SECRET:-}
BASE_URL: ${BASE_URL:-http://localhost:3000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
# Loopback only: nginx terminates TLS and proxies to 127.0.0.1:3000.
# Published on every interface this would also answer on http://<ip>:3000,
# which is the same site without TLS — and BASE_URL being https there means
# the session cookie is Secure, so logging in that way silently fails.
ports:
- "3000:3000"
- "127.0.0.1:3000:3000"
depends_on:
migrate:
condition: service_completed_successfully

12
package.json Normal file
View File

@ -0,0 +1,12 @@
{
"name": "szabadpalya",
"version": "0.1.0",
"private": true,
"description": "Repository root. The frontend is its own package under web/ with its own lockfile; this file exists only so the operational scripts have a name you can run from here.",
"scripts": {
"deploy": "./scripts/deploy.sh",
"ci": "./scripts/ci.sh",
"up": "./scripts/up.sh"
},
"packageManager": "pnpm@11.8.0"
}

9
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,9 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.: {}

View File

@ -166,7 +166,7 @@ step "seed loads" seed_ci
boot_app() {
docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$APP_CONTAINER" -p "${APP_PORT}:3000" \
docker run -d --name "$APP_CONTAINER" -p "127.0.0.1:${APP_PORT}:3000" \
--add-host=host.docker.internal:host-gateway \
--health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=3 \
-e DATABASE_URL="postgresql://${DB_USER}:${DB_USER}@host.docker.internal:5433/${CI_DB}" \
@ -293,7 +293,7 @@ step "docker reports container healthy" check_docker_health
# pass as healthy. Uses a throwaway container so the real database is untouched.
check_health_degraded() {
docker rm -f "$SICK_CONTAINER" >/dev/null 2>&1 || true
docker run -d --name "$SICK_CONTAINER" -p "${SICK_PORT}:3000" \
docker run -d --name "$SICK_CONTAINER" -p "127.0.0.1:${SICK_PORT}:3000" \
--health-interval=2s --health-timeout=3s --health-start-period=2s --health-retries=2 \
-e DATABASE_URL="postgresql://nobody:nobody@127.0.0.1:5999/nothing" \
-e SESSION_SECRET="ci-only-secret" \

View File

@ -4,6 +4,10 @@
# Uses the docker compose plugin when it is available (that is the case on the
# server); when it is not, it brings up the same container with plain docker run.
#
# Either way the port is published on 127.0.0.1 only, matching compose.yaml:
# docker's published ports bypass UFW, so loopback is what keeps the database
# off the internet on a host that has a public address.
#
# ./scripts/db.sh up | down | psql | logs | reset
set -euo pipefail
@ -27,7 +31,7 @@ up() {
docker volume create "$VOLUME" >/dev/null
docker run -d --name "$NAME" --restart unless-stopped \
-e POSTGRES_USER="$USER" -e POSTGRES_PASSWORD="$PASS" -e POSTGRES_DB="$DB" \
-p "$PORT:5432" -v "$VOLUME:/var/lib/postgresql/data" \
-p "127.0.0.1:$PORT:5432" -v "$VOLUME:/var/lib/postgresql/data" \
--health-cmd "pg_isready -U $USER -d $DB" --health-interval 5s \
"$IMAGE" >/dev/null
fi

296
scripts/deploy.sh Executable file
View File

@ -0,0 +1,296 @@
#!/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/tty
case "$reply" in [yY]*) ;; *) echo " aborted"; exit 1 ;; esac
fi
# ----------------------------------------------------------------- build ----
# Before the copy: a compile error should stop the deploy while the server is
# still untouched and still serving the previous release.
if [ "$BUILD" = 1 ] && [ "$SERVER_BUILD" = 0 ]; then
bold "Building locally"
docker build --target runner -t "$IMAGE:$TAG" -t "$IMAGE:latest" . \
|| die "the build failed here — $HOST was not touched"
SIZE=$(docker image inspect "$IMAGE:$TAG" --format '{{.Size}}' | awk '{printf "%.0f MB", $1/1048576}')
ok "$IMAGE:$TAG built ($SIZE)"
fi
bold "Copying the source"
rsync "${RSYNC_OPTS[@]}" --info=stats1 ./ "$HOST:$REMOTE_DIR/"
ok "files in place"
[ "$BUILD" = 1 ] || { echo; ok "copied; skipped the build and the restart as asked"; exit 0; }
# ------------------------------------------------------------------ ship ----
if [ "$SERVER_BUILD" = 0 ]; then
bold "Shipping the image"
LOCAL_ID=$(docker image inspect "$IMAGE:$TAG" --format '{{.Id}}')
REMOTE_ID=$(remote "docker image inspect $IMAGE:$TAG --format '{{.Id}}' 2>/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:<older-tag> $IMAGE:latest && docker compose up -d\""