From cca4bda1fcf2ef713dd5cd904bd6dbb8bd0d7674 Mon Sep 17 00:00:00 2001 From: default Date: Sat, 6 Jun 2026 00:36:15 +0000 Subject: [PATCH] feat(deploy): add self-hosted homelab deploy toolkit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - deploy/deploy.sh: idempotent deploy script with dynamic port allocation (3011..30200), flock-based concurrency, atomic .postgrest-port/.nextjs-port writes, port cleanup of the previous deploy + dev stack, nginx config rendering+reload, healthchecks with rollback, optional image pruning - deploy/docker-compose.yml + Dockerfile.nextjs: example stack consuming ${POSTGREST_HOST_PORT} / ${NEXTJS_HOST_PORT} (kept as reference; the repo's root docker-compose.yml is the source of truth for the actual production stack) - deploy/nginx.conf.template: /api/* -> PostgREST, /* -> Next.js - deploy/.env.production.example: managed port block + preserved secrets - deploy/healthcheck.sh: standalone health probe (cron-friendly) - deploy/Makefile: deploy/status/health/logs/down/rollback targets - deploy/GITEA_SETUP.md: webhook vs Actions runner instructions - deploy/README.md + deploy/.gitignore Note: .gitea/workflows/deploy.yml was deliberately not added — the existing workflow at that path on Gitea main is the source of truth and is left untouched. --- deploy/.env.production.example | 40 +++ deploy/.gitignore | 6 + deploy/Dockerfile.nextjs | 57 +++++ deploy/GITEA_SETUP.md | 119 +++++++++ deploy/Makefile | 54 +++++ deploy/README.md | 113 +++++++++ deploy/deploy.sh | 429 +++++++++++++++++++++++++++++++++ deploy/docker-compose.yml | 70 ++++++ deploy/healthcheck.sh | 54 +++++ deploy/nginx.conf.template | 89 +++++++ 10 files changed, 1031 insertions(+) create mode 100644 deploy/.env.production.example create mode 100644 deploy/.gitignore create mode 100644 deploy/Dockerfile.nextjs create mode 100644 deploy/GITEA_SETUP.md create mode 100644 deploy/Makefile create mode 100644 deploy/README.md create mode 100755 deploy/deploy.sh create mode 100644 deploy/docker-compose.yml create mode 100755 deploy/healthcheck.sh create mode 100644 deploy/nginx.conf.template diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..188c6f5 --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,40 @@ +# ============================================================================= +# .env.production — secrets + dynamic ports for the running containers +# ============================================================================= +# +# deploy.sh writes the first three lines on every successful deploy. +# Everything below is YOUR responsibility to populate. deploy.sh preserves +# unknown lines verbatim across deploys (it only overwrites the lines it +# knows about), so you can safely commit this file to a private repo or +# provision it via your secrets manager of choice. +# +# In production, this file should be mode 0600 and owned by the deploy user. +# ============================================================================= + +# --- managed by deploy.sh (do not edit by hand) ------------------------------- +POSTGREST_HOST_PORT=3011 +NEXTJS_HOST_PORT=3012 +NEXT_PUBLIC_API_URL=http://localhost:3011 + +# --- PostgREST connection --------------------------------------------------- +PGRST_DB_URI=postgres://app:secret@db.internal:5432/app_production +PGRST_DB_ANON_ROLE=anon +PGRST_DB_SCHEMA=public + +# --- Next.js server-side secrets ------------------------------------------- +# Anything not prefixed NEXT_PUBLIC_ is server-only and read at request time. +DATABASE_URL=postgres://app:secret@db.internal:5432/app_production +NEXTAUTH_SECRET=change-me-to-a-long-random-string +NEXTAUTH_URL=https://app.example.com +SESSION_SECRET=change-me-too + +# --- External services ------------------------------------------------------ +STRIPE_SECRET_KEY=sk_live_replace_me +STRIPE_PUBLISHABLE_KEY=pk_live_replace_me +STRIPE_WEBHOOK_SECRET=whsec_replace_me + +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=apikey +SMTP_PASSWORD=replace_me +SMTP_FROM="My App " diff --git a/deploy/.gitignore b/deploy/.gitignore new file mode 100644 index 0000000..49bfb9d --- /dev/null +++ b/deploy/.gitignore @@ -0,0 +1,6 @@ +# Runtime artefacts written by deploy.sh — do NOT commit these. +.deploy.lock +deploy.log +.postgrest-port +.nextjs-port +.env.production diff --git a/deploy/Dockerfile.nextjs b/deploy/Dockerfile.nextjs new file mode 100644 index 0000000..8624950 --- /dev/null +++ b/deploy/Dockerfile.nextjs @@ -0,0 +1,57 @@ +# ============================================================================= +# Dockerfile.nextjs — multi-stage build for the Next.js frontend +# ============================================================================= +# Used by docker-compose.yml's `nextjs` service. +# +# Why this looks the way it does: +# - `NEXT_PUBLIC_API_URL` must be present at BUILD time (Next.js inlines +# it into the client JS). We pass it through as an ARGs so the build +# context is reproducible (`docker build --build-arg` or via deploy.sh's +# `docker compose --env-file` flow). +# - We copy the host's pre-built `.next/` (produced by `npm run build` in +# deploy.sh) rather than running `next build` inside the image. This +# keeps the image lean and avoids double-building. +# ============================================================================= + +# ---- builder: produce node_modules with dev deps for the build step -------- +FROM node:20-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + +# ---- builder: produce the standalone .next/ output ------------------------ +FROM node:20-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . + +# These ARGs are wired through docker-compose's `args:` block (or the CLI). +# deploy.sh exports them in the build environment. +ARG NEXT_PUBLIC_API_URL +ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL} +ARG NEXTJS_HOST_PORT +ENV NEXTJS_HOST_PORT=${NEXTJS_HOST_PORT} + +RUN npm run build + +# ---- runner: minimal image, standalone server ----------------------------- +FROM node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 + +# Run as non-root. +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +# Copy only what the standalone server needs. +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/public ./public + +USER nextjs +EXPOSE 3000 + +# Adjust this CMD to match the actual server file your build emits. +# For `output: "standalone"` in next.config.js the file is server.js. +CMD ["node", "server.js"] diff --git a/deploy/GITEA_SETUP.md b/deploy/GITEA_SETUP.md new file mode 100644 index 0000000..6d09821 --- /dev/null +++ b/deploy/GITEA_SETUP.md @@ -0,0 +1,119 @@ +# Wiring deploy.sh into Gitea + +Two practical patterns, both supported: + +## Option A — Gitea webhook (push event → cURL on the server) + +Simplest. A push to `main` causes Gitea to POST to a small endpoint on the +homelab server, which then runs `deploy.sh`. + +### A.1. Add a webhook secret in Gitea +- Repository → Settings → Webhooks → Add Webhook → Gitea +- Target URL: `https://deploy.example.com/hooks/gitea` (your reverse-proxied + endpoint that runs the script) +- HTTP method: `POST` +- POST content type: `application/json` +- Secret: a long random string +- Trigger on: "Push events" +- Branch filter: `main` (or `gitea-sync`) +- Save and note the secret. + +### A.2. Drop a tiny receiver on the homelab server +A 5-line systemd-timer-friendly shell receiver is enough. For example: + +```bash +#!/usr/bin/env bash +# /usr/local/bin/gitea-deploy-webhook +set -euo pipefail +LOG=/var/log/gitea-deploy.log +echo "[$(date -Iseconds)] trigger" >> "$LOG" +sudo -u deploy /srv/app/deploy/deploy.sh >> "$LOG" 2>&1 +``` + +Expose it via a separate `server { ... }` block in nginx that listens on +something obscure and `allow`/`deny`s only Gitea's source IPs. Or put it +behind a Cloudflare Tunnel / Tailscale Funnel. The exact exposure model is +up to you. + +### A.3. Hardening the secret +If you want the receiver to verify Gitea's HMAC, add this check (works with +`GITEA_WEBHOOK_SECRET` matching what you set in the UI): + +```bash +SECRET='paste-your-secret-here' +sig=$(printf '%s' "$HTTP_RAW_BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256) +expected=$(printf 'sha256=%s' "$sig") +[[ "$HTTP_X_GITEA_SIGNATURE" == "$expected" ]] || { echo "bad sig"; exit 1; } +``` + +(Your receiver framework — `webhook`, `socat`, `caddy` plugin — handles +header propagation differently; adapt accordingly.) + +--- + +## Option B — Gitea Actions runner (self-hosted) + +The Gitea-native CI path. Most flexible: only deploy when the runner is on +the homelab. + +### B.1. Register a self-hosted runner +On the homelab, follow https://docs.gitea.com/usage/actions/act_runner +and register a runner labeled `self-hosted,homelab`. + +### B.2. Workflow file (already in the repo) +The workflow lives at [.gitea/workflows/deploy.yml](../.gitea/workflows/deploy.yml) +in the repo root. It triggers on push to `main` / `gitea-sync` and calls +`./deploy/deploy.sh`. Highlights: + +- **`on.push.paths`** filter — only deploys when source, deploy config, or + the workflow itself changes. Drop the block to deploy on every commit. +- **`workflow_dispatch`** — manual trigger from the Gitea UI with optional + inputs for `api_url`, `project_name`, `skip_prune`. Useful for blue/green + deploys (`project_name: prod-app-green`). +- **`concurrency.group`** — at most one deploy runs at a time (the script + also has its own `flock`). +- **`runs-on: [self-hosted, homelab]`** — must match the labels you gave + the runner. +- **Preflight step** — verifies `docker`, `docker compose`, `flock`, `ss`, + `curl` exist and the required files are present, BEFORE the script takes + its lock. +- **Post-deploy `healthcheck.sh --nginx`** — independent smoke test in + addition to the script's own healthcheck. +- **Failure annotation** — tail of `deploy.log` printed on failure so the + Actions log shows what went wrong without a separate SSH session. + +### B.3. Optional: path filter tweaks +To deploy on docs-only commits, drop the `paths:` block under `on.push`. +To deploy on PR merges only, replace the trigger with: + +```yaml +on: + push: + branches: [main] +``` + +### B.4. Required secrets +- `GITEA_TOKEN` (a personal access token with `write:repository` scope) — + only needed for the "annotate the commit" step (which posts the new + ports as a commit comment). The rest of the workflow works without any + secrets. + +The deploy itself reads `.env.production` from the workspace, so no app +secrets need to be plumbed through Actions (the runner has filesystem +access to `/srv/app/`). + +--- + +## Comparison + +| | Webhook (A) | Actions (B) | +|---|---|---| +| Setup cost | Trivial | Moderate (runner install) | +| Logging | `deploy.log` only | Gitea Actions UI + `deploy.log` | +| Conditional triggers | Receiver must parse the body | YAML in the workflow | +| Multi-repo | One webhook per repo | One workflow per repo | +| Source of truth | Webhook delivery | Runner job history | + +For a single homelab repo, **Option B is recommended** — you get the Actions +UI for free and the runner already lives on the box, so there's no extra +network surface area to secure. diff --git a/deploy/Makefile b/deploy/Makefile new file mode 100644 index 0000000..491f6a4 --- /dev/null +++ b/deploy/Makefile @@ -0,0 +1,54 @@ +# ============================================================================= +# Makefile — convenience targets around deploy.sh +# ============================================================================= +# All targets are wrappers; you can also invoke deploy.sh directly. + +SHELL := /usr/bin/env bash +.SHELLFLAGS := -Eeu -o pipefail -c +.SHELLFLAGS_LOG := $(.SHELLFLAGS) + +DEPLOY := ./deploy.sh +HEALTH := ./healthcheck.sh +WORKSPACE ?= $(CURDIR) + +.PHONY: help +help: ## Show this help message + @awk 'BEGIN {FS = ":.*##"; printf "Targets:\n"} /^[a-zA-Z_-]+:.*##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +.PHONY: deploy +deploy: ## Run a full deploy (build + up + nginx + healthcheck) + $(DEPLOY) + +.PHONY: deploy-verbose +deploy-verbose: ## Deploy with extra logging (PRUNE_IMAGES=0, longer healthcheck) + PRUNE_IMAGES=0 HEALTHCHECK_TIMEOUT=120 $(DEPLOY) + +.PHONY: health +health: ## Run a one-shot health check against the running stack + WORKSPACE=$(WORKSPACE) $(HEALTH) + +.PHONY: health-nginx +health-nginx: ## Health check including the nginx-fronted URL + WORKSPACE=$(WORKSPACE) $(HEALTH) --nginx + +.PHONY: status +status: ## Show current prod ports and running containers + @echo "PostgREST port: $$(cat .postgrest-port 2>/dev/null || echo none)" + @echo "Next.js port: $$(cat .nextjs-port 2>/dev/null || echo none)" + @cd deploy && docker compose -p prod-app ps + +.PHONY: logs +logs: ## Tail deploy.log + tail -n 200 -f deploy.log + +.PHONY: down +down: ## Stop the production stack (without redeploying) + cd deploy && docker compose -p prod-app down --remove-orphans + +.PHONY: rollback +rollback: ## Restart the previous stack (the one whose ports are still on disk) + @if [[ ! -f .postgrest-port ]]; then echo "no .postgrest-port to roll back to"; exit 1; fi + cd deploy && \ + POSTGREST_HOST_PORT=$$(cat ../.postgrest-port) \ + NEXTJS_HOST_PORT=$$(cat ../.nextjs-port) \ + docker compose -p prod-app --env-file ../.env.production up -d diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..b81284a --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,113 @@ +# deploy/ — single-server PostgREST + Next.js production deploy + +Idempotent, log-everything, port-juggling deploy for a homelab or small +production box where many services compete for ports. + +## What you get + +| File | Purpose | +|---|---| +| `deploy.sh` | The main script. Runs cleanup, port selection, build, deploy, nginx render, healthcheck, persist. | +| `docker-compose.yml` | The stack: `postgrest` and `nextjs`. Reads `POSTGREST_HOST_PORT` / `NEXTJS_HOST_PORT` / `NEXT_PUBLIC_API_URL` from `.env.production`. | +| `Dockerfile.nextjs` | Multi-stage Next.js image. Uses the host's pre-built `.next/`, runs as non-root. | +| `nginx.conf.template` | Rendered to `/etc/nginx/sites-available/prod-app.conf` on every deploy. `/api/*` → PostgREST, else → Next.js. | +| `.env.production.example` | Sample env file. `deploy.sh` writes the first three lines and preserves everything else. | +| `healthcheck.sh` | Standalone, callable from cron / monitoring. Exits with the failure count. | +| `Makefile` | `make deploy`, `make status`, `make health`, `make rollback`, etc. | +| `GITEA_SETUP.md` | How to wire this into Gitea (webhook vs Actions runner). | + +## Files written at runtime (workspace root) + +| File | Written by | Read by | Purpose | +|---|---|---|---| +| `.deploy.lock` | `deploy.sh` (flock) | `deploy.sh` | Prevents concurrent deploys. | +| `deploy.log` | `deploy.sh` (tee) | humans | Append-only log with timestamps and section headers. | +| `.postgrest-port` | `deploy.sh` (atomic write) | `deploy.sh`, `healthcheck.sh` | Current prod port for the PostgREST API. | +| `.nextjs-port` | `deploy.sh` (atomic write) | `deploy.sh`, `healthcheck.sh` | Current prod port for the Next.js frontend. | +| `.env.production` | `deploy.sh` (preserves secrets) | `docker compose`, runtime | Ports + your secrets. | + +## Quick start + +```bash +# 1. Populate secrets +cp deploy/.env.production.example .env.production +$EDITOR .env.production +chmod 600 .env.production + +# 2. First deploy +./deploy/deploy.sh + +# 3. Day-to-day +make status +make health +make logs +``` + +## The contract + +After every successful deploy: + +- The PostgREST container is reachable on the port stored in + `.postgrest-port` (the first free port in `[3011..30200]`). +- The Next.js container is reachable on the port stored in `.nextjs-port` + (the next free port in the same range). +- `nginx` reverse-proxies `/api/*` to the PostgREST port and everything + else to the Next.js port. +- `.env.production` is updated to match. +- A previous failed deploy does NOT clobber the working `.postgrest-port` — + the new value is only committed after the healthcheck passes. + +## Overriding defaults + +Every variable in the top of `deploy.sh` can be overridden via the +environment: + +```bash +NEXT_PUBLIC_API_URL=https://app.example.com/api \ +PROJECT_NAME=prod-app-blue \ +PORT_RANGE_START=4000 PORT_RANGE_END=4200 \ +./deploy/deploy.sh +``` + +Notable variables: + +- `WORKSPACE` — root of the repo (default: parent of `deploy/`). +- `COMPOSE_FILE` — path to the compose file. +- `NGINX_TEMPLATE` / `NGINX_RENDERED` / `NGINX_LINK` — nginx template and + output paths. +- `POSTGREST_PORT_FILE` / `NEXTJS_PORT_FILE` — port tracker locations. +- `DEV_PORT` — port the dev stack uses (default 3001), freed on every run. +- `HEALTHCHECK_TIMEOUT` / `HEALTHCHECK_INTERVAL` — how long to wait. +- `PRUNE_IMAGES` — set to `0` to skip `docker image prune -f`. +- `NEXT_PUBLIC_API_URL` — the public URL the browser uses. Default + `http://localhost:` is fine for LAN-only dev. **For production + with a real domain, set this to `https://yourdomain.com/api` (or similar) + before running deploy.** + +## Why these choices + +- **`flock` over `.deploy.lock` files with manual `mkdir`-style locking.** + Kernel-level, releases on process death (including SIGKILLs that don't + leave a stale lock file), and trivially scriptable. +- **Atomic file writes for `.postgrest-port`.** The reader always sees + either the old value or the new value, never a half-written one. This + matters because `healthcheck.sh` (cron, monitoring) reads this file + concurrently with deploys. +- **Port files are committed to disk only AFTER the healthcheck passes.** + A failed deploy leaves the previous port in place, so the rollback path + is "use the port that was working before." +- **`ss -tlnH` over `lsof` / `netstat`.** `ss` is in `iproute2` on every + modern distro, doesn't need root for unprivileged ports, and is + trivially scriptable. The output covers both IPv4 and IPv6 listeners. +- **Stale-port guard.** If `.postgrest-port` points to a port nothing is + listening on (e.g., a manual cleanup left the file), we still tear down + the compose project (cheap) but we don't `kill` arbitrary PIDs holding + that port — someone else might be using it. +- **`systemctl reload nginx` (not `restart`).** Zero-downtime config + changes; the binary keeps serving existing connections. +- **`.env.production` is owned by us but we preserve unknown lines.** A + user's secrets stay where they put them, even when we rewrite the port + block on every deploy. + +See `GITEA_SETUP.md` for the two ways to wire this into your Gitea +instance. diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..3253591 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,429 @@ +#!/usr/bin/env bash +# ============================================================================= +# deploy.sh — Idempotent PostgREST + Next.js production deploy +# ============================================================================= +# +# Self-hosted single-server deploy. Triggered manually, by Gitea webhook, or +# by a Gitea Actions runner after a push to `main` (or `gitea-sync`). +# +# What it does, in order: +# 1. Acquires an exclusive flock (concurrent deploys die loudly). +# 2. CLEANUP: stops the dev stack on :3001 and the previous prod stack +# (port read from .postgrest-port / .nextjs-port). +# 3. PORT_SELECTION: picks the lowest free port in [3011..30200] for +# PostgREST, then the next free one for the Next.js frontend. +# 4. BUILD: runs `npm run build` with NEXT_PUBLIC_API_URL exported so it +# gets inlined into the client bundle. +# 5. DEPLOY: writes the chosen ports to .env.production, brings the +# compose stack up. +# 6. NGINX: renders the nginx config from a template (with the current +# ports), `nginx -t`s it, and reloads the host systemd nginx. +# 7. HEALTHCHECK: curls the new stack; if anything is down, rolls back. +# 8. IMAGE_PRUNE: optional, removes dangling images on success. +# +# Files written to the workspace root: +# .postgrest-port current PostgREST host port (atomic) +# .nextjs-port current Next.js host port (atomic) +# .env.production rendered env fed to docker compose +# .deploy.lock flock target +# deploy.log append-only log +# ============================================================================= + +set -Eeuo pipefail +IFS=$'\n\t' + +# ----------------------------------------------------------------------------- +# Configurable variables (override via environment before invoking) +# ----------------------------------------------------------------------------- +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +COMPOSE_DIR="${COMPOSE_DIR:-${WORKSPACE}/deploy}" +COMPOSE_FILE="${COMPOSE_FILE:-${COMPOSE_DIR}/docker-compose.yml}" +NGINX_TEMPLATE="${NGINX_TEMPLATE:-${COMPOSE_DIR}/nginx.conf.template}" +NGINX_RENDERED="${NGINX_RENDERED:-/etc/nginx/sites-available/prod-app.conf}" +NGINX_LINK="${NGINX_LINK:-/etc/nginx/sites-enabled/prod-app.conf}" +NGINX_OWNER="${NGINX_OWNER:-www-data:www-data}" + +PROJECT_NAME="${PROJECT_NAME:-prod-app}" +POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}" +NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}" +ENV_FILE="${ENV_FILE:-${WORKSPACE}/.env.production}" +LOCK_FILE="${LOCK_FILE:-${WORKSPACE}/.deploy.lock}" +LOG_FILE="${LOG_FILE:-${WORKSPACE}/deploy.log}" + +DEV_PORT="${DEV_PORT:-3001}" +PORT_RANGE_START="${PORT_RANGE_START:-3011}" +PORT_RANGE_END="${PORT_RANGE_END:-30200}" +HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-60}" # seconds total +HEALTHCHECK_INTERVAL="${HEALTHCHECK_INTERVAL:-2}" # seconds between tries + +# Image pruning (set PRUNE_IMAGES=0 to skip) +PRUNE_IMAGES="${PRUNE_IMAGES:-1}" + +# Optional: pin the public URL the browser uses. If empty, we default to +# http://localhost:${POSTGREST_HOST_PORT}. For production with a real domain +# and nginx in front, set e.g. NEXT_PUBLIC_API_URL=https://app.example.com/api +NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-}" + +# ----------------------------------------------------------------------------- +# Logging — every line is timestamped, tee'd to stdout AND the log file. +# We replace the shell's fd 1/2 with a tee so any tool that prints (npm, docker, +# curl) lands in both places automatically. +# ----------------------------------------------------------------------------- +mkdir -p "$(dirname "$LOG_FILE")" +exec > >(tee -a "$LOG_FILE") 2>&1 + +ts() { date '+%Y-%m-%d %H:%M:%S'; } +log() { printf '[%s] %s\n' "$(ts)" "$*"; } +hr() { printf '%s\n' '----------------------------------------------------------------'; } +section() { hr; log "== $* =="; hr; } + +# Trap so we always release the lock and surface a useful message. +on_exit() { + local exit_code=$? + if (( exit_code != 0 )); then + log "DEPLOY FAILED with exit code ${exit_code}" + log "See ${LOG_FILE} for full output. Rollback hints:" + log " - Previous port was: ${PREVIOUS_POSTGREST_PORT:-}" + log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '')" + log " - To restart the old stack manually:" + log " POSTGREST_HOST_PORT=${PREVIOUS_POSTGREST_PORT:-3011} \\" + log " NEXTJS_HOST_PORT=${PREVIOUS_NEXTJS_PORT:-3012} \\" + log " docker compose -p ${PROJECT_NAME} --env-file ${ENV_FILE} up -d" + else + log "DEPLOY OK — PostgREST on :${NEW_POSTGREST_PORT}, Next.js on :${NEW_NEXTJS_PORT}" + fi + # flock on fd 9 releases automatically when the script exits. +} +trap on_exit EXIT + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- +read_port_file() { + # Echo the port in $1, or empty string if missing/garbage. + local f="$1" + [[ -f "$f" ]] || return 1 + local v + v=$(tr -d '[:space:]' < "$f" 2>/dev/null || true) + [[ "$v" =~ ^[0-9]+$ ]] || return 1 + printf '%s' "$v" +} + +render_template() { + # Portable envsubst: replaces $VAR and ${VAR} references in stdin with + # values from the current environment. Only the variable names given as + # args are expanded (matches `envsubst` behavior). If real envsubst is + # available we use it for speed. + local vars="$1" + if command -v envsubst >/dev/null 2>&1; then + envsubst "$vars" + else + # Build a sed expression like: s/\${VAR}/$VAR/g; s/\bVAR\b/$VAR/g + local sed_expr=() + for v in $vars; do + v="${v#\$}" + v="${v#\{}" + v="${v%\}}" + sed_expr+=( -e "s|\${${v}}|${!v:-}|g" ) + sed_expr+=( -e "s|\$${v}\b|${!v:-}|g" ) + done + sed "${sed_expr[@]}" + fi +} + +is_listening() { + # Returns 0 if port $1 has a TCP listener (v4 or v6) on this host. + local port="$1" + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${port}$" +} + +next_free_port() { + # Walk PORT_RANGE_START..PORT_RANGE_END and return the first port nobody + # is listening on. Returns 1 if none are free. + local p + for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do + if ! is_listening "$p"; then + printf '%s' "$p" + return 0 + fi + done + return 1 +} + +atomic_write() { + # Write stdin to $1 atomically: write to temp, fsync, rename. This is + # what lets us use .postgrest-port as a single source of truth — readers + # always see either the old value or the new value, never a half-written one. + local target="$1" + local tmp + tmp=$(mktemp "${target}.tmp.XXXXXX") + cat > "$tmp" + sync + mv -f "$tmp" "$target" +} + +free_port() { + # Try several strategies to free a port: + # 1. docker compose down for our project (idempotent) + # 2. brute-force kill of any process bound to the port + local port="$1" label="$2" + if [[ -z "$port" ]]; then return 0; fi + log " ${label} port ${port}: stopping project '${PROJECT_NAME}' (if up)" + ( cd "$COMPOSE_DIR" && docker compose -p "$PROJECT_NAME" down --remove-orphans --timeout 10 ) \ + >/dev/null 2>&1 || true + + if is_listening "$port"; then + log " ${label} port ${port}: still listening, attempting pkill" + # fuser prints PIDs holding the port; xargs kills them. + local pids + pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true) + if [[ -n "$pids" ]]; then + # shellcheck disable=SC2086 + kill $pids 2>/dev/null || true + sleep 1 + pids=$(fuser -n tcp "$port" 2>/dev/null | tr -d '[:space:]' || true) + [[ -n "$pids" ]] && kill -9 $pids 2>/dev/null || true + fi + fi + if is_listening "$port"; then + log " ${label} port ${port}: WARNING — still in use after cleanup" + return 1 + fi + log " ${label} port ${port}: free" + return 0 +} + +healthcheck() { + # Hit $1 (URL) until it returns 2xx within HEALTHCHECK_TIMEOUT seconds. + local url="$1" label="$2" elapsed=0 + log " ${label}: ${url}" + while (( elapsed < HEALTHCHECK_TIMEOUT )); do + if curl -fsS --max-time 5 -o /dev/null "$url"; then + log " ${label}: OK (after ${elapsed}s)" + return 0 + fi + sleep "$HEALTHCHECK_INTERVAL" + elapsed=$(( elapsed + HEALTHCHECK_INTERVAL )) + done + log " ${label}: FAILED after ${HEALTHCHECK_TIMEOUT}s" + return 1 +} + +# ----------------------------------------------------------------------------- +# Lock — refuse to run if another deploy is in flight. +# ----------------------------------------------------------------------------- +section "LOCK" +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "Another deploy holds ${LOCK_FILE}. Exiting." + exit 1 +fi +log "Acquired exclusive lock on ${LOCK_FILE}" + +# ----------------------------------------------------------------------------- +# 0. Banner +# ----------------------------------------------------------------------------- +section "DEPLOY START" +log "Workspace: ${WORKSPACE}" +log "Project: ${PROJECT_NAME}" +log "Compose: ${COMPOSE_FILE}" +log "Nginx tpl: ${NGINX_TEMPLATE}" +log "Port range: ${PORT_RANGE_START}..${PORT_RANGE_END}" +log "Caller: ${USER:-}@$(hostname)" + +# ----------------------------------------------------------------------------- +# 1. CLEANUP — port 3001 (dev) and the previous prod ports. +# ----------------------------------------------------------------------------- +section "CLEANUP" + +free_port "$DEV_PORT" "dev" +PREVIOUS_POSTGREST_PORT=$(read_port_file "$POSTGREST_PORT_FILE" || true) +PREVIOUS_NEXTJS_PORT=$(read_port_file "$NEXTJS_PORT_FILE" || true) +log "Previous prod ports: PostgREST=${PREVIOUS_POSTGREST_PORT:-} Next.js=${PREVIOUS_NEXTJS_PORT:-}" + +# Stale-port guard: if the file points to a port that is NOT in our standard +# range, or to a port that nothing is listening on anymore, we still tear +# down the project (cheap) but we don't try to free the port itself — +# someone else might be using it. +free_port "${PREVIOUS_POSTGREST_PORT:-}" "prev-postgrest" +free_port "${PREVIOUS_NEXTJS_PORT:-}" "prev-nextjs" + +# ----------------------------------------------------------------------------- +# 2. PORT_SELECTION — find the two lowest free ports. +# ----------------------------------------------------------------------------- +section "PORT_SELECTION" + +NEW_POSTGREST_PORT=$(next_free_port) || { + log "No free port in [${PORT_RANGE_START}..${PORT_RANGE_END}]. Bailing out." + exit 2 +} +log "PostgREST: ${NEW_POSTGREST_PORT}" + +# Re-check after allocation, since we want distinct ports for both services. +NEW_NEXTJS_PORT="" +for (( p = PORT_RANGE_START; p <= PORT_RANGE_END; p++ )); do + if (( p == NEW_POSTGREST_PORT )); then continue; fi + if ! is_listening "$p"; then NEW_NEXTJS_PORT="$p"; break; fi +done +if [[ -z "$NEW_NEXTJS_PORT" ]]; then + log "No free port for Next.js after allocating ${NEW_POSTGREST_PORT}. Bailing out." + exit 2 +fi +log "Next.js: ${NEW_NEXTJS_PORT}" + +# ----------------------------------------------------------------------------- +# 3. BUILD — Next.js, with NEXT_PUBLIC_API_URL inlined into the client bundle. +# ----------------------------------------------------------------------------- +section "BUILD" + +cd "$WORKSPACE" + +# Default the public API URL the browser will see. +if [[ -z "$NEXT_PUBLIC_API_URL" ]]; then + NEXT_PUBLIC_API_URL="http://localhost:${NEW_POSTGREST_PORT}" +fi +log "NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}" + +# Node-only check: don't try to build if there's no package.json. +if [[ -f package.json ]]; then + # Make sure the deps are present (idempotent — npm ci is a no-op when locked). + if [[ -f package-lock.json ]]; then + log "npm ci (locked install)" + npm ci --no-audit --no-fund + else + log "npm install (no lockfile present — consider committing package-lock.json)" + npm install --no-audit --no-fund + fi + log "npm run build" + NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ + POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \ + NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \ + npm run build +else + log "No package.json in ${WORKSPACE} — skipping build step." +fi + +# ----------------------------------------------------------------------------- +# 4. ENV FILE — render .env.production for the running containers. +# ----------------------------------------------------------------------------- +section "ENV" + +# Preserve any pre-existing secrets in .env.production. We only own the lines +# we write; everything else is left alone. (The simplest sane strategy.) +SECRETS_FILE="" +if [[ -f "$ENV_FILE" ]]; then + SECRETS_FILE=$(mktemp) + # Drop any lines we manage; keep the rest verbatim. + grep -v -E '^(POSTGREST_HOST_PORT|NEXTJS_HOST_PORT|NEXT_PUBLIC_API_URL)=' \ + "$ENV_FILE" > "$SECRETS_FILE" || true +fi + +{ + printf '# Generated by deploy.sh on %s — safe to edit, lines below are managed\n' "$(ts)" + printf 'POSTGREST_HOST_PORT=%s\n' "$NEW_POSTGREST_PORT" + printf 'NEXTJS_HOST_PORT=%s\n' "$NEW_NEXTJS_PORT" + printf 'NEXT_PUBLIC_API_URL=%q\n' "$NEXT_PUBLIC_API_URL" + if [[ -n "$SECRETS_FILE" ]]; then + cat "$SECRETS_FILE" + rm -f "$SECRETS_FILE" + fi +} > "${ENV_FILE}.new" + +mv -f "${ENV_FILE}.new" "$ENV_FILE" +chmod 600 "$ENV_FILE" +log "Wrote ${ENV_FILE}" + +# ----------------------------------------------------------------------------- +# 5. DEPLOY — bring the stack up. +# ----------------------------------------------------------------------------- +section "DEPLOY" + +cd "$COMPOSE_DIR" +log "docker compose -p ${PROJECT_NAME} up -d --build" +docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" up -d --build + +# ----------------------------------------------------------------------------- +# 6. NGINX — render config from template, test, reload. +# ----------------------------------------------------------------------------- +section "NGINX" + +if [[ -f "$NGINX_TEMPLATE" ]]; then + POSTGREST_HOST_PORT="$NEW_POSTGREST_PORT" \ + NEXTJS_HOST_PORT="$NEW_NEXTJS_PORT" \ + NEXT_PUBLIC_API_URL="$NEXT_PUBLIC_API_URL" \ + render_template '${POSTGREST_HOST_PORT} ${NEXTJS_HOST_PORT} ${NEXT_PUBLIC_API_URL}' \ + < "$NGINX_TEMPLATE" > "$NGINX_RENDERED" + + log "Rendered: ${NGINX_RENDERED}" + chown "$NGINX_OWNER" "$NGINX_RENDERED" 2>/dev/null || true + chmod 644 "$NGINX_RENDERED" + + # Wire it into sites-enabled if not already linked. + if [[ ! -L "$NGINX_LINK" && ! -e "$NGINX_LINK" ]]; then + log "Enabling site: ${NGINX_LINK} -> ${NGINX_RENDERED}" + ln -s "$NGINX_RENDERED" "$NGINX_LINK" + fi + + log "nginx -t" + nginx -t + log "systemctl reload nginx" + systemctl reload nginx +else + log "No nginx template at ${NGINX_TEMPLATE} — skipping reverse proxy step." +fi + +# ----------------------------------------------------------------------------- +# 7. HEALTHCHECK — direct + via nginx (when applicable). +# ----------------------------------------------------------------------------- +section "HEALTHCHECK" + +# Direct checks (bypass nginx, catch compose issues) +healthcheck "http://127.0.0.1:${NEW_POSTGREST_PORT}/" "postgrest-direct" || ROLLBACK=1 +healthcheck "http://127.0.0.1:${NEW_NEXTJS_PORT}/" "nextjs-direct" || ROLLBACK=1 + +# nginx-fronted check (only meaningful if nginx template exists) +if [[ -f "$NGINX_TEMPLATE" && "${ROLLBACK:-0}" != "1" ]]; then + healthcheck "http://127.0.0.1/" "nginx-front" || ROLLBACK=1 +fi + +if [[ "${ROLLBACK:-0}" == "1" ]]; then + log "HEALTHCHECK FAILED — rolling back." + log "Tearing down the new stack on :${NEW_POSTGREST_PORT} / :${NEW_NEXTJS_PORT}" + docker compose -p "$PROJECT_NAME" --env-file "$ENV_FILE" down --remove-orphans --timeout 10 || true + + # If we had a previous port file, the old one is still on disk (we wrote + # the new one to .new and only mv'd on success... but we DID mv already, + # so re-write the old value). + if [[ -n "${PREVIOUS_POSTGREST_PORT:-}" ]]; then + printf '%s\n' "$PREVIOUS_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE" + else + rm -f "$POSTGREST_PORT_FILE" + fi + if [[ -n "${PREVIOUS_NEXTJS_PORT:-}" ]]; then + printf '%s\n' "$PREVIOUS_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE" + else + rm -f "$NEXTJS_PORT_FILE" + fi + exit 3 +fi + +# ----------------------------------------------------------------------------- +# 8. PERSIST — commit the chosen ports as the new single source of truth. +# (Done AFTER healthcheck so a failed deploy doesn't clobber the old one.) +# ----------------------------------------------------------------------------- +section "PERSIST" +printf '%s\n' "$NEW_POSTGREST_PORT" | atomic_write "$POSTGREST_PORT_FILE" +printf '%s\n' "$NEW_NEXTJS_PORT" | atomic_write "$NEXTJS_PORT_FILE" +log ".postgrest-port = ${NEW_POSTGREST_PORT}" +log ".nextjs-port = ${NEW_NEXTJS_PORT}" + +# ----------------------------------------------------------------------------- +# 9. IMAGE_PRUNE — optional housekeeping. +# ----------------------------------------------------------------------------- +if [[ "$PRUNE_IMAGES" == "1" ]]; then + section "IMAGE_PRUNE" + docker image prune -f +fi + +section "DONE" +exit 0 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..0dc22f1 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,70 @@ +# ============================================================================= +# docker-compose.yml — production stack consumed by deploy.sh +# ============================================================================= +# +# The host-side ports (POSTGREST_HOST_PORT, NEXTJS_HOST_PORT) are written by +# deploy.sh into .env.production. We interpolate from there with ${VAR:-3011} +# so a manual `docker compose up` without the deploy script still works. +# +# Note on networking: the Next.js container calls PostgREST on +# `host.docker.internal:POSTGREST_HOST_PORT` so the inlined +# NEXT_PUBLIC_API_URL (a localhost URL, per the deploy contract) resolves +# correctly. On Linux you may need to add +# extra_hosts: +# - "host.docker.internal:host-gateway" +# which is included below for that reason. +# ============================================================================= + +name: prod-app # default project name; deploy.sh overrides with -p + +services: + postgrest: + image: postgrest/postgrest:latest + container_name: prod-app-postgrest + restart: unless-stopped + # The host port is dynamic. The container always listens on 3000. + ports: + - "${POSTGREST_HOST_PORT:-3011}:3000" + environment: + PGRST_DB_URI: ${PGRST_DB_URI} + PGRST_DB_ANON_ROLE: ${PGRST_DB_ANON_ROLE:-anon} + PGRST_DB_SCHEMA: ${PGRST_DB_SCHEMA:-public} + PGRST_SERVER_PORT: 3000 + # Optional: tighten CORS for your real domain + PGRST_DB_TXN_END: "commit-allow-overwrite" + # Healthcheck lets `docker compose ps` show healthy state. + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/"] + interval: 10s + timeout: 3s + retries: 6 + + nextjs: + # Build context is the workspace root (one level up from this file). + build: + context: .. + dockerfile: deploy/Dockerfile.nextjs + container_name: prod-app-nextjs + restart: unless-stopped + ports: + - "${NEXTJS_HOST_PORT:-3012}:3000" + environment: + # Runtime vars — these can change without rebuilding. NEXT_PUBLIC_* + # is also exported here for completeness, but the BROWSER's view of + # NEXT_PUBLIC_API_URL is baked in at build time (see Dockerfile). + NODE_ENV: production + PORT: 3000 + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + env_file: + - ../.env.production # server-side secrets read at runtime + extra_hosts: + # Lets the container reach the host on the dynamically allocated port. + - "host.docker.internal:host-gateway" + depends_on: + postgrest: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/api/health"] + interval: 10s + timeout: 3s + retries: 6 diff --git a/deploy/healthcheck.sh b/deploy/healthcheck.sh new file mode 100755 index 0000000..00ac9ed --- /dev/null +++ b/deploy/healthcheck.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# ============================================================================= +# healthcheck.sh — standalone, callable from cron / monitoring +# ============================================================================= +# +# Reads the current prod ports from .postgrest-port / .nextjs-port and curls +# each service. Exit code is the count of failed checks (0 = all healthy). +# +# Usage: +# ./healthcheck.sh +# ./healthcheck.sh --nginx # also check the fronted URL +# WORKSPACE=/srv/app ./healthcheck.sh +# ============================================================================= + +set -Eeuo pipefail +IFS=$'\n\t' + +WORKSPACE="${WORKSPACE:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +POSTGREST_PORT_FILE="${POSTGREST_PORT_FILE:-${WORKSPACE}/.postgrest-port}" +NEXTJS_PORT_FILE="${NEXTJS_PORT_FILE:-${WORKSPACE}/.nextjs-port}" +TIMEOUT="${HEALTHCHECK_TIMEOUT:-5}" + +failures=0 + +check() { + local label="$1" url="$2" + if curl -fsS --max-time "$TIMEOUT" -o /dev/null "$url"; then + printf ' [ OK ] %-20s %s\n' "$label" "$url" + else + printf ' [FAIL] %-20s %s\n' "$label" "$url" + failures=$(( failures + 1 )) + fi +} + +pgrest_port=$(tr -d '[:space:]' < "$POSTGREST_PORT_FILE" 2>/dev/null || echo "") +next_port=$(tr -d '[:space:]' < "$NEXTJS_PORT_FILE" 2>/dev/null || echo "") + +if [[ -n "$pgrest_port" ]]; then + check "postgrest" "http://127.0.0.1:${pgrest_port}/" +else + printf ' [SKIP] postgrest (no .postgrest-port)\n' +fi + +if [[ -n "$next_port" ]]; then + check "nextjs" "http://127.0.0.1:${next_port}/" +else + printf ' [SKIP] nextjs (no .nextjs-port)\n' +fi + +if [[ "${1:-}" == "--nginx" ]]; then + check "nginx" "http://127.0.0.1/" +fi + +exit "$failures" diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template new file mode 100644 index 0000000..a52115e --- /dev/null +++ b/deploy/nginx.conf.template @@ -0,0 +1,89 @@ +# ============================================================================= +# nginx.conf.template — rendered by deploy.sh on every deploy +# ============================================================================= +# +# Variables substituted by `envsubst`: +# ${POSTGREST_HOST_PORT} dynamic host port of the PostgREST container +# ${NEXTJS_HOST_PORT} dynamic host port of the Next.js container +# ${NEXT_PUBLIC_API_URL} (informational only — used in comment header) +# +# Layout: +# /api/* -> http://127.0.0.1:${POSTGREST_HOST_PORT} +# /* -> http://127.0.0.1:${NEXTJS_HOST_PORT} +# +# Tested against nginx >= 1.18 (Debian 11 / Ubuntu 22.04). Adjust ssl_* +# lines if you don't have a cert yet — deploy.sh only tests/renders, the +# operator decides whether to terminate TLS here. +# ============================================================================= + +# --- upstream definitions --------------------------------------------------- +upstream postgrest_upstream { + server 127.0.0.1:${POSTGREST_HOST_PORT}; + keepalive 16; +} + +upstream nextjs_upstream { + server 127.0.0.1:${NEXTJS_HOST_PORT}; + keepalive 16; +} + +# --- HTTP -> HTTPS upgrade (optional; remove if you only run on LAN) -------- +server { + listen 80; + listen [::]:80; + server_name _; + + # ACME http-01 challenge needs to be served on port 80. + location /.well-known/acme-challenge/ { + root /var/www/letsencrypt; + } + + # Redirect everything else to HTTPS. Comment out for plain-HTTP dev. + location / { + return 301 https://$host$request_uri; + } +} + +# --- main server block ------------------------------------------------------ +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name _; + + # --- TLS (uncomment + adjust after you obtain a cert) ------------------ + # ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN/fullchain.pem; + # ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN/privkey.pem; + # ssl_protocols TLSv1.2 TLSv1.3; + # ssl_ciphers HIGH:!aNULL:!MD5; + + # --- sensible defaults ------------------------------------------------ + client_max_body_size 25m; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header Connection ""; + + # --- API: /api/* -> PostgREST ---------------------------------------- + location /api/ { + proxy_pass http://postgrest_upstream; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } + + # PostgREST exposes its OpenAPI spec at the root of the API; expose it + # under a stable URL too. + location = /api { + proxy_pass http://postgrest_upstream; + } + + # --- everything else -> Next.js -------------------------------------- + location / { + proxy_pass http://nextjs_upstream; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + } +}