diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..a8e57f8 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,288 @@ +name: Deploy to route.crispygoat.com + +on: + push: + branches: + - main + +jobs: + deploy: + # Self-hosted Gitea runner (matches build.yml). The Gitea Actions runner + # is registered with the [self-hosted, linux, ubuntu-latest] labels. + runs-on: [self-hosted, linux, ubuntu-latest] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Start Docker stack + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + + # Free the dev-stack port (3001) and the port the previous deploy used + # (so a new deploy can pick it back up if it's the lowest free port) + PREV_PORT=$(cat .postgrest-port 2>/dev/null || echo "") + for port in 3001 $PREV_PORT; do + if [ -n "$port" ] && ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${port}[[:space:]]"; then + echo "Port $port in use, freeing..." + fuser -k -9 $port/tcp 2>/dev/null || true + docker ps -aq --filter "publish=$port" 2>/dev/null | xargs -r docker rm -f 2>/dev/null || true + fi + done + + # Hard-stop the previous stack. Errors are NOT swallowed: if down + # fails, picking a port against a half-torn-down stack is exactly + # what produces the TOCTOU "address already in use" we keep hitting. + docker compose -f $APP_DIR/docker-compose.yml down --remove-orphans + # Belt-and-braces: anything with the postgrest name that survived. + docker ps -aq --filter "name=route_commerce_postgrest" | xargs -r docker rm -f >/dev/null 2>&1 || true + # docker-proxy sometimes leaves a listener behind for the published port. + pkill -9 -f 'docker-proxy.*3011' 2>/dev/null || true + pkill -9 -f 'docker-proxy.*3012' 2>/dev/null || true + pkill -9 -f 'docker-proxy.*3013' 2>/dev/null || true + sleep 3 + + # Verify the postgrest container is actually gone before we pick a port. + if docker ps -aq --filter "name=route_commerce_postgrest" | grep -q .; then + echo "ERROR: route_commerce_postgrest still running after down" + docker ps --filter "name=route_commerce_postgrest" + exit 1 + fi + + # Find the first free host port starting from 3011. Persist the choice + # so the Build and Deploy steps below can use the same URL. + POSTGREST_HOST_PORT=3011 + for attempt in 1 2 3 4 5 6 7 8 9 10; do + if ! ss -tln 2>/dev/null | grep -qE "[[:space:]]127\.0\.0\.1:${POSTGREST_HOST_PORT}[[:space:]]"; then + break + fi + echo "Port $POSTGREST_HOST_PORT in use, trying next... (attempt $attempt)" + POSTGREST_HOST_PORT=$((POSTGREST_HOST_PORT + 1)) + if [ $POSTGREST_HOST_PORT -gt 30200 ]; then + echo "ERROR: no free port in 3011-30200 range" + exit 1 + fi + sleep 1 + done + echo "Using PostgREST host port: $POSTGREST_HOST_PORT" + echo "$POSTGREST_HOST_PORT" > .postgrest-port + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + export POSTGREST_HOST_PORT + + # Seed config files into APP_DIR if missing (they live in the repo, not in APP_DIR) + [ -f $APP_DIR/.env.example ] || cp .env.example $APP_DIR/.env.example + [ -f $APP_DIR/docker-compose.yml ] || cp docker-compose.yml $APP_DIR/docker-compose.yml + cd $APP_DIR + [ -f .env ] || cp .env.example .env + # Append production secrets to .env (overriding .env.example defaults) + { + echo "POSTGRES_USER=${POSTGRES_USER}" + echo "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}" + echo "POSTGRES_DB=${POSTGRES_DB}" + echo "MINIO_ROOT_USER=${MINIO_ROOT_USER}" + echo "MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD}" + echo "POSTGREST_JWT_SECRET=${POSTGRES_JWT_SECRET}" + echo "POSTGREST_HOST_PORT=$POSTGREST_HOST_PORT" + echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL" + } >> .env + # Bring the stack up fresh — --force-recreate ensures no stale + # network/container references from prior failed attempts + docker compose up -d --force-recreate db postgrest minio minio_init + # Wait for Postgres healthcheck + for i in $(seq 1 30); do + if docker compose exec -T db pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" > /dev/null 2>&1; then + echo "Postgres is ready" + break + fi + sleep 2 + done + + - name: Apply migrations + env: + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + run: | + APP_DIR=/home/tyler/route-commerce + # Seed supabase/ into APP_DIR if missing (the deploy step copies it after, but + # we need it here for migrations) + [ -d $APP_DIR/supabase ] || cp -r supabase $APP_DIR/supabase + cd $APP_DIR + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/migrations/000_preflight_supabase_compat.sql || true + [ -f supabase/captured_schema.sql ] && PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -f supabase/captured_schema.sql || echo "captured_schema.sql not present, skipping" + for f in supabase/migrations/[0-9]*.sql; do + PGPASSWORD="${POSTGRES_PASSWORD}" psql -h 127.0.0.1 -U "${POSTGRES_USER}" -d "${POSTGRES_DB}" -v ON_ERROR_STOP=0 -q -f "$f" || echo "FAIL: $f" + done + + - name: Install dependencies + run: npm install + + - name: Build + env: + NODE_ENV: production + DATABASE_URL: ${{ secrets.DATABASE_URL }} + + # ── Auth.js v5 (NextAuth) ──────────────────────────────────────── + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + AUTH_GOOGLE_ID: ${{ secrets.AUTH_GOOGLE_ID }} + AUTH_GOOGLE_SECRET: ${{ secrets.AUTH_GOOGLE_SECRET }} + ALLOW_DEV_LOGIN: ${{ secrets.ALLOW_DEV_LOGIN }} + + # ── Storage (MinIO / S3) ───────────────────────────────────────── + NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} + STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} + STORAGE_REGION: ${{ secrets.STORAGE_REGION }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} + + # ── Stripe ─────────────────────────────────────────────────────── + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + + # ── Resend ─────────────────────────────────────────────────────── + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} + + # ── AI providers (local code references these) ────────────────── + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + + # ── Email sender ───────────────────────────────────────────────── + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + run: | + POSTGREST_HOST_PORT=$(cat .postgrest-port) + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + npm run build + + - name: Deploy + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + POSTGRES_USER: ${{ secrets.POSTGRES_USER }} + POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }} + POSTGRES_DB: ${{ secrets.POSTGRES_DB }} + MINIO_ROOT_USER: ${{ secrets.MINIO_ROOT_USER }} + MINIO_ROOT_PASSWORD: ${{ secrets.MINIO_ROOT_PASSWORD }} + POSTGREST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # Auth.js v5 + AUTH_SECRET: ${{ secrets.AUTH_SECRET }} + AUTH_URL: ${{ secrets.AUTH_URL }} + NEXT_PUBLIC_AUTH_URL: ${{ secrets.NEXT_PUBLIC_AUTH_URL }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + + # Storage + STORAGE_ENDPOINT: ${{ secrets.STORAGE_ENDPOINT }} + STORAGE_REGION: ${{ secrets.STORAGE_REGION }} + STORAGE_ACCESS_KEY: ${{ secrets.STORAGE_ACCESS_KEY }} + STORAGE_SECRET_KEY: ${{ secrets.STORAGE_SECRET_KEY }} + STORAGE_BUCKET_PREFIX: ${{ secrets.STORAGE_BUCKET_PREFIX }} + NEXT_PUBLIC_STORAGE_BASE_URL: ${{ secrets.NEXT_PUBLIC_STORAGE_BASE_URL }} + + # PostgREST + PGRST_SERVER_PORT: ${{ secrets.PGRST_SERVER_PORT }} + PGRST_DB_URI: ${{ secrets.PGRST_DB_URI }} + PGRST_DB_ANON_ROLE: ${{ secrets.PGRST_DB_ANON_ROLE }} + PGRST_JWT_SECRET: ${{ secrets.POSTGREST_JWT_SECRET }} + + # Stripe + STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }} + STRIPE_PUBLISHABLE_KEY: ${{ secrets.STRIPE_PUBLISHABLE_KEY }} + + # Resend + RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }} + RESEND_WEBHOOK_SECRET: ${{ secrets.RESEND_WEBHOOK_SECRET }} + + # AI + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }} + XAI_API_KEY: ${{ secrets.XAI_API_KEY }} + + FROM_EMAIL: ${{ secrets.FROM_EMAIL }} + run: | + APP_DIR=/home/tyler/route-commerce + mkdir -p $APP_DIR + # Use the port chosen by Start Docker stack (persisted to .postgrest-port) + POSTGREST_HOST_PORT=$(cat .postgrest-port) + export NEXT_PUBLIC_API_URL="http://localhost:$POSTGREST_HOST_PORT" + + # Write env file from secrets (preserves existing .env for docker compose) + { + printf "DATABASE_URL=%s\n" "$DATABASE_URL" + printf "NEXT_PUBLIC_API_URL=%s\n" "$NEXT_PUBLIC_API_URL" + printf "POSTGRES_USER=%s\n" "$POSTGRES_USER" + printf "POSTGRES_PASSWORD=%s\n" "$POSTGRES_PASSWORD" + printf "POSTGRES_DB=%s\n" "$POSTGRES_DB" + printf "MINIO_ROOT_USER=%s\n" "$MINIO_ROOT_USER" + printf "MINIO_ROOT_PASSWORD=%s\n" "$MINIO_ROOT_PASSWORD" + printf "POSTGREST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "AUTH_SECRET=%s\n" "$AUTH_SECRET" + printf "AUTH_URL=%s\n" "$AUTH_URL" + printf "NEXT_PUBLIC_AUTH_URL=%s\n" "$NEXT_PUBLIC_AUTH_URL" + printf "GOOGLE_CLIENT_ID=%s\n" "$GOOGLE_CLIENT_ID" + printf "GOOGLE_CLIENT_SECRET=%s\n" "$GOOGLE_CLIENT_SECRET" + printf "STORAGE_ENDPOINT=%s\n" "$STORAGE_ENDPOINT" + printf "STORAGE_REGION=%s\n" "$STORAGE_REGION" + printf "STORAGE_ACCESS_KEY=%s\n" "$STORAGE_ACCESS_KEY" + printf "STORAGE_SECRET_KEY=%s\n" "$STORAGE_SECRET_KEY" + printf "STORAGE_BUCKET_PREFIX=%s\n" "$STORAGE_BUCKET_PREFIX" + printf "NEXT_PUBLIC_STORAGE_BASE_URL=%s\n" "$NEXT_PUBLIC_STORAGE_BASE_URL" + printf "PGRST_SERVER_PORT=%s\n" "$PGRST_SERVER_PORT" + printf "PGRST_DB_URI=%s\n" "$PGRST_DB_URI" + printf "PGRST_DB_ANON_ROLE=%s\n" "$PGRST_DB_ANON_ROLE" + printf "PGRST_JWT_SECRET=%s\n" "$POSTGREST_JWT_SECRET" + printf "STRIPE_SECRET_KEY=%s\n" "$STRIPE_SECRET_KEY" + printf "STRIPE_WEBHOOK_SECRET=%s\n" "$STRIPE_WEBHOOK_SECRET" + printf "STRIPE_PUBLISHABLE_KEY=%s\n" "$STRIPE_PUBLISHABLE_KEY" + printf "RESEND_API_KEY=%s\n" "$RESEND_API_KEY" + printf "RESEND_WEBHOOK_SECRET=%s\n" "$RESEND_WEBHOOK_SECRET" + printf "OPENAI_API_KEY=%s\n" "$OPENAI_API_KEY" + printf "ANTHROPIC_API_KEY=%s\n" "$ANTHROPIC_API_KEY" + printf "GOOGLE_API_KEY=%s\n" "$GOOGLE_API_KEY" + printf "XAI_API_KEY=%s\n" "$XAI_API_KEY" + printf "FROM_EMAIL=%s\n" "$FROM_EMAIL" + } > $APP_DIR/.env.production + + # Copy build output and required files + rsync -a --delete .next/ $APP_DIR/.next/ + rsync -a --delete public/ $APP_DIR/public/ + cp package.json $APP_DIR/ + cp docker-compose.yml $APP_DIR/ + cp -r supabase/ $APP_DIR/ + cp next.config.ts $APP_DIR/ 2>/dev/null || cp next.config.js $APP_DIR/ 2>/dev/null || true + + # Install production deps only + cd $APP_DIR + npm install --omit=dev + + # Start or restart PM2 process + if pm2 describe route-commerce > /dev/null 2>&1; then + pm2 restart route-commerce + else + pm2 start npm --name route-commerce -- start -- -p 3100 + pm2 save + fi + + echo "Deployed successfully" diff --git a/deploy/.env.production.example b/deploy/.env.production.example new file mode 100644 index 0000000..57d7afe --- /dev/null +++ b/deploy/.env.production.example @@ -0,0 +1,52 @@ +# ============================================================================= +# .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 + +# Auth.js v5 (NextAuth). Generate AUTH_SECRET with `npx auth secret` or +# `openssl rand -base64 32`. AUTH_URL is the public base URL the browser +# uses to build OAuth callback URLs. +AUTH_SECRET=change-me-to-a-long-random-string +AUTH_URL=https://app.example.com +NEXT_PUBLIC_AUTH_URL=https://app.example.com +ALLOW_DEV_LOGIN=false + +# Google OAuth provider for Auth.js. Set both AUTH_GOOGLE_ID/SECRET and +# GOOGLE_CLIENT_ID/SECRET (the v5 code reads either name). +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +AUTH_GOOGLE_ID= +AUTH_GOOGLE_SECRET= + +# --- 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/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/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; + } +}