- 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.
4.4 KiB
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(orgitea-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:
#!/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/denys 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):
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
in the repo root. It triggers on push to main / gitea-sync and calls
./deploy/deploy.sh. Highlights:
on.push.pathsfilter — 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 forapi_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 ownflock).runs-on: [self-hosted, homelab]— must match the labels you gave the runner.- Preflight step — verifies
docker,docker compose,flock,ss,curlexist 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.logprinted 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:
on:
push:
branches: [main]
B.4. Required secrets
GITEA_TOKEN(a personal access token withwrite:repositoryscope) — 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.