Add deployment tooling for a single production VPS
The repo had no infrastructure at all: no Dockerfile, no nginx config, no systemd unit, no deploy script, no CI. Deployment guidance existed only as two prose lines in each README. Target shape is one reg.ru KVM box serving all four landings, each on its own domain root. nginx serves the built SPA off disk and proxies only /api/* to a small Express process per app, so a crashed or restarting API takes down the form rather than the page. Releases are built on the server into a timestamped directory and swapped in by renaming the `current` symlink. deploy/apps.conf domains and ports, the one place to edit deploy/README.md hardware/software spec, provisioning, runbook deploy/bin/ exo-deploy, exo-render-nginx deploy/nginx/ vhost template, shared snippets, http extras deploy/systemd/ one template unit for all four instances deploy/env/ annotated templates for the two env files exo-deploy asserts the VITE_SITE_URL substitution actually happened, prunes devDependencies after building, precompresses for gzip_static, health-checks after the swap and rolls back on its own if the new release fails to answer. exo-render-nginx refuses to emit a vhost while a placeholder domain is still in apps.conf. Secrets are split across two env files: the amoCRM token stays in /etc/exo at mode 640 and is injected by systemd, while only public build-time vars are copied into a release. dotenv does not override variables already in the environment, so the two coexist safely. Chose systemd over Docker deliberately — no database, no conflicting runtimes, no CI or registry to build images in, and containerising fights the serve-static-from-disk design. Reasoning and the triggers to revisit it are recorded in the README. Domains are still placeholders; nothing here has been run against a server yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build one landing from git into a fresh release directory and swap it in.
|
||||
# Install as /usr/local/bin/exo-deploy (mode 755); run as the `exo` user.
|
||||
#
|
||||
# sudo -u exo exo-deploy medcenter # deploys origin/main
|
||||
# sudo -u exo exo-deploy medcenter my-branch
|
||||
set -Eeuo pipefail
|
||||
|
||||
APP="${1:?usage: exo-deploy <fitnes|hotel|medcenter|medcenterpersonal> [git-ref]}"
|
||||
REF="${2:-main}"
|
||||
BASE="/srv/exo/$APP"
|
||||
ENVFILE="/etc/exo/$APP.env"
|
||||
BUILDENV="/etc/exo/$APP.build.env"
|
||||
KEEP=3
|
||||
REL="$BASE/releases/$(date -u +%Y%m%d-%H%M%S)"
|
||||
|
||||
for f in "$ENVFILE" "$BUILDENV"; do
|
||||
[[ -r $f ]] || { echo "FATAL: cannot read $f"; exit 1; }
|
||||
done
|
||||
[[ -d $BASE/repo ]] || { echo "FATAL: no bare repo at $BASE/repo"; exit 1; }
|
||||
|
||||
PORT="$(sed -n 's/^PORT=//p' "$ENVFILE")"
|
||||
[[ -n $PORT ]] || { echo "FATAL: PORT not set in $ENVFILE"; exit 1; }
|
||||
|
||||
echo "==> fetching $REF"
|
||||
git -C "$BASE/repo" fetch --prune origin '+refs/heads/*:refs/heads/*'
|
||||
git -C "$BASE/repo" rev-parse --verify "$REF^{commit}" >/dev/null
|
||||
|
||||
echo "==> extracting $APP/ into $REL"
|
||||
mkdir -p "$REL"
|
||||
git -C "$BASE/repo" archive "$REF" "$APP" | tar -x -C "$REL" --strip-components=1
|
||||
[[ -f $REL/package.json ]] || { echo "FATAL: $APP/ not found at $REF"; rm -rf "$REL"; exit 1; }
|
||||
|
||||
# 2.6-3.9 MB of base64-embedded reference HTML that is never served.
|
||||
rm -rf "$REL/legacy"
|
||||
|
||||
# Public build-time vars only. The amoCRM token stays in $ENVFILE, which
|
||||
# systemd injects at runtime; dotenv does not override real env vars, so the
|
||||
# two never collide.
|
||||
cp "$BUILDENV" "$REL/.env"
|
||||
|
||||
cd "$REL"
|
||||
|
||||
# devDependencies are REQUIRED here: vite, typescript and tailwindcss all live
|
||||
# there and `npm run build` needs them. --omit=dev breaks the build.
|
||||
echo "==> npm ci"
|
||||
npm ci --no-audit --no-fund
|
||||
|
||||
echo "==> npm run build"
|
||||
npm run build
|
||||
|
||||
# Fail loudly rather than shipping broken SEO tags or an empty bundle.
|
||||
[[ -s dist/client/index.html ]] || { echo "FATAL: dist/client/index.html missing or empty"; exit 1; }
|
||||
if grep -q '%VITE_SITE_URL%' dist/client/index.html; then
|
||||
echo "FATAL: VITE_SITE_URL was not substituted — check $BUILDENV"; exit 1
|
||||
fi
|
||||
|
||||
# Runtime needs only express/zod/dotenv/tsx: ~130 MB -> ~40 MB per release.
|
||||
echo "==> pruning devDependencies"
|
||||
npm prune --omit=dev
|
||||
|
||||
# Precompress for nginx gzip_static. .webp/.woff2 omitted on purpose.
|
||||
find dist/client -type f \
|
||||
\( -name '*.js' -o -name '*.css' -o -name '*.html' -o -name '*.svg' -o -name '*.json' -o -name '*.xml' -o -name '*.txt' \) \
|
||||
-exec gzip -9 -k -f {} +
|
||||
|
||||
echo "==> swapping $BASE/current -> $REL"
|
||||
PREV="$(readlink -f "$BASE/current" 2>/dev/null || true)"
|
||||
ln -sfn "$REL" "$BASE/current.tmp"
|
||||
mv -Tf "$BASE/current.tmp" "$BASE/current" # single rename(2): atomic
|
||||
|
||||
sudo systemctl restart "exo@$APP"
|
||||
|
||||
echo "==> waiting for health on 127.0.0.1:$PORT"
|
||||
for _ in $(seq 1 20); do
|
||||
if curl -fsS --max-time 2 "http://127.0.0.1:$PORT/api/health" >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
HEALTH="$(curl -fsS --max-time 5 "http://127.0.0.1:$PORT/api/health")" || {
|
||||
echo "FATAL: health check failed. Rolling back."
|
||||
[[ -n $PREV ]] && { ln -sfn "$PREV" "$BASE/current.tmp"; mv -Tf "$BASE/current.tmp" "$BASE/current"; sudo systemctl restart "exo@$APP"; }
|
||||
exit 1
|
||||
}
|
||||
echo " $HEALTH"
|
||||
grep -q '"ok":true' <<<"$HEALTH" || { echo "FATAL: health not ok"; exit 1; }
|
||||
grep -q '"amo":true' <<<"$HEALTH" || echo " WARNING: amo=false — /api/leads/* will return 503. Check AMO_* in $ENVFILE."
|
||||
|
||||
# Prune old releases, never the live one.
|
||||
CURRENT="$(readlink -f "$BASE/current")"
|
||||
ls -1dt "$BASE"/releases/*/ 2>/dev/null | tail -n "+$((KEEP+1))" | while read -r old; do
|
||||
[[ "$(readlink -f "$old")" == "$CURRENT" ]] && continue
|
||||
rm -rf "$old"
|
||||
done
|
||||
|
||||
echo "==> deployed $APP @ $REF ($(git -C "$BASE/repo" rev-parse --short "$REF")) -> $REL"
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Render one nginx vhost per app from deploy/apps.conf + nginx/site.conf.template.
|
||||
#
|
||||
# deploy/bin/exo-render-nginx # writes to ./rendered/
|
||||
# deploy/bin/exo-render-nginx /etc/nginx/sites-available
|
||||
set -Eeuo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUT="${1:-$HERE/rendered}"
|
||||
TPL="$HERE/nginx/site.conf.template"
|
||||
mkdir -p "$OUT"
|
||||
|
||||
while IFS='|' read -r app domain port tag; do
|
||||
[[ -z ${app:-} || $app == \#* ]] && continue
|
||||
if [[ $domain == *_DOMAIN ]]; then
|
||||
echo "SKIP $app: domain is still the placeholder '$domain' — edit deploy/apps.conf" >&2
|
||||
continue
|
||||
fi
|
||||
sed -e "s/{{APP}}/$app/g" -e "s/{{DOMAIN}}/$domain/g" -e "s/{{PORT}}/$port/g" \
|
||||
"$TPL" > "$OUT/exo-$app.conf"
|
||||
echo "wrote $OUT/exo-$app.conf ($domain -> 127.0.0.1:$port)"
|
||||
done < "$HERE/apps.conf"
|
||||
Reference in New Issue
Block a user