cca4bda1fc
- deploy/deploy.sh: idempotent deploy script with dynamic port
allocation (3011..30200), flock-based concurrency, atomic
.postgrest-port/.nextjs-port writes, port cleanup of the previous
deploy + dev stack, nginx config rendering+reload, healthchecks
with rollback, optional image pruning
- deploy/docker-compose.yml + Dockerfile.nextjs: example stack
consuming ${POSTGREST_HOST_PORT} / ${NEXTJS_HOST_PORT} (kept as
reference; the repo's root docker-compose.yml is the source of
truth for the actual production stack)
- deploy/nginx.conf.template: /api/* -> PostgREST, /* -> Next.js
- deploy/.env.production.example: managed port block + preserved secrets
- deploy/healthcheck.sh: standalone health probe (cron-friendly)
- deploy/Makefile: deploy/status/health/logs/down/rollback targets
- deploy/GITEA_SETUP.md: webhook vs Actions runner instructions
- deploy/README.md + deploy/.gitignore
Note: .gitea/workflows/deploy.yml was deliberately not added — the
existing workflow at that path on Gitea main is the source of truth
and is left untouched.
deploy/ — 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
# 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). nginxreverse-proxies/api/*to the PostgREST port and everything else to the Next.js port..env.productionis 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:
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 ofdeploy/).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 to0to skipdocker image prune -f.NEXT_PUBLIC_API_URL— the public URL the browser uses. Defaulthttp://localhost:<port>is fine for LAN-only dev. For production with a real domain, set this tohttps://yourdomain.com/api(or similar) before running deploy.
Why these choices
flockover.deploy.lockfiles with manualmkdir-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 becausehealthcheck.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 -tlnHoverlsof/netstat.ssis iniproute2on 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-portpoints 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'tkillarbitrary PIDs holding that port — someone else might be using it. systemctl reload nginx(notrestart). Zero-downtime config changes; the binary keeps serving existing connections..env.productionis 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.