feat(deploy): add self-hosted homelab deploy toolkit
Deploy to route.crispygoat.com / deploy (push) Failing after 15s
Build / build (push) Has been cancelled

- 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:
2026-06-06 00:36:15 +00:00
parent ffed10cd66
commit cca4bda1fc
10 changed files with 1031 additions and 0 deletions
+119
View File
@@ -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.