Files
route-commerce/deploy
tyler cca4bda1fc
Deploy to route.crispygoat.com / deploy (push) Failing after 15s
Build / build (push) Has been cancelled
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.
2026-06-06 00:47:44 +00:00
..

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).
  • 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:

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.