feat(deploy): add self-hosted homelab deploy toolkit
- 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.
This commit is contained in:
@@ -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 <noreply@example.com>"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Runtime artefacts written by deploy.sh — do NOT commit these.
|
||||||
|
.deploy.lock
|
||||||
|
deploy.log
|
||||||
|
.postgrest-port
|
||||||
|
.nextjs-port
|
||||||
|
.env.production
|
||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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:<port>` 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.
|
||||||
Executable
+429
@@ -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:-<unknown>}"
|
||||||
|
log " - Current .postgrest-port value: $(read_port_file "$POSTGREST_PORT_FILE" || echo '<none>')"
|
||||||
|
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:-<unknown>}@$(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:-<none>} Next.js=${PREVIOUS_NEXTJS_PORT:-<none>}"
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -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
|
||||||
Executable
+54
@@ -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"
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user