CivicPlexus Self-Hosting & Deployment Guide
A non-developer can follow this guide end-to-end and stand up a secure, production-ready CivicPlexus deployment — with or without Lovable — in roughly two hours. Each section ends with a Did it work? check.
CivicPlexus is a full local-government HRIS + talent suite (recruiting, onboarding, core HR, payroll, benefits, performance, compliance), not just an onboarding tool. Plan capacity accordingly.
1. What you'll end up with
┌──────────────────┐
Users ─────► │ Cloudflare CDN │ ──► HTTPS, WAF, rate limiting
└────────┬─────────┘
▼
┌──────────────────┐
│ SSR runtime │ ──► TanStack Start app (SSR + API)
│ (Workers / Node)│
└────────┬─────────┘
▼
┌──────────────────┐
│ Supabase Project │ ──► Postgres + Auth + Storage + pg_cron
└────────┬─────────┘
▼
┌──────────────────┐
│ Email Provider │ ──► Resend / SES / SendGrid
└──────────────────┘
Cost estimate (small municipality, < 500 employees):
- Supabase Pro: ~$25/mo
- Cloudflare Workers Paid or Render/Fly Starter: ~$5–$20/mo
- Resend or SES: ~$0–$20/mo
- Domain: ~$15/yr
- Total: ~$35–$70/month
Time required: ~2 hours (plus DNS propagation, up to 24 hours).
2. Decisions before you start
| Decision | Recommendation | Alternatives |
|---|---|---|
| Database | Supabase Cloud | Self-hosted Supabase on a VM |
| Deployment target | Cloudflare Workers | Lovable-hosted (one click); generic Node host (Render / Fly.io / Railway); Docker on a VM |
| Domain | One you already own (e.g. hr.cityofx.gov) |
Buy at registrar of choice |
| Resend (easiest) | AWS SES, SendGrid, Postmark | |
| Backups | Supabase automated daily | pg_dump to S3 if self-hosted |
This guide covers every deployment target in §8.
3. Accounts to create
Sign up for all of these now and turn on two-factor authentication for each:
- GitHub — https://github.com (free)
- Supabase — https://supabase.com (free tier OK to start; upgrade to Pro before go-live)
- Your deployment host — Cloudflare, Render, Fly.io, Railway, or your VM provider
- Domain registrar — whoever sold you the domain
- Resend — https://resend.com (free tier OK to start)
Did it work? ✅ You can log into all five with MFA enabled.
4. Get the code
Install prerequisites
- macOS:
brew install git node bun - Windows: install Git for Windows, Node LTS, then in PowerShell:
irm bun.sh/install.ps1 | iex - Linux:
sudo apt install git nodejs && curl -fsSL https://bun.sh/install | bash
Clone and install
git clone https://github.com/<your-org>/civicplexus.git
cd civicplexus
bun install
Did it work? ✅ bun install completes without errors.
5. Provision the database (Supabase Cloud — recommended)
- https://supabase.com → New project. Pick a strong DB password and save it in a password manager. Region: closest to your users.
- Once provisioned, go to Settings → API and copy these into your password manager:
- Project URL (e.g.
https://abcd.supabase.co) anon(publishable) keyservice_rolekey (treat like a root password — never paste into client code)
- Project URL (e.g.
- Install the Supabase CLI:
bun add -g supabase - Link and push migrations:
supabase login supabase link --project-ref <your-project-ref> supabase db push - Auth → Providers: enable Email and Google. For Google, follow the in-product wizard to register OAuth credentials.
- Auth → Email: enable Have I Been Pwned password check. Disable Confirm email only if you have a closed user base.
- Auth → URL Configuration: set Site URL to your final domain (e.g.
https://hr.cityofx.gov). Add the same to Redirect URLs. - Storage: create these private buckets (leave "Public" off on all of them):
onboarding-documentsjob-applicationstraining-evidenceoffer-letterspayroll-exportscompliance-exportsofficial-photosofficial-documents
Did it work? ✅ supabase db push reports "Finished". The Tables UI shows employees, job_requisitions, payroll_runs, performance_reviews, etc.
6. Alternative: self-hosted Supabase on a single VM
Skip if you used §5.
- Provision a VM (Hetzner CCX23, DigitalOcean 4 vCPU/8 GB, or AWS Lightsail $40 plan).
- Install Docker + Docker Compose.
git clone https://github.com/supabase/supabase && cd supabase/docker- Copy
.env.exampleto.env, generate strong values forPOSTGRES_PASSWORD,JWT_SECRET,ANON_KEY,SERVICE_ROLE_KEY(use https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys). docker compose up -d- Install Caddy as a reverse proxy with automatic Let's Encrypt:
hr.cityofx.gov { reverse_proxy localhost:3000 } - Schedule nightly
pg_dumpto S3/B2:0 2 * * * docker exec supabase-db pg_dumpall -U postgres | gzip | aws s3 cp - s3://your-backups/$(date +\%F).sql.gz - Test the restore before you go live.
Did it work? ✅ https://hr.cityofx.gov returns the Supabase Studio login.
7. Configure secrets
Create .env.local in the project root (never commit this file):
# Client-visible (safe to bundle)
VITE_SUPABASE_URL=https://abcd.supabase.co
VITE_SUPABASE_PUBLISHABLE_KEY=eyJhbGci...
VITE_SUPABASE_PROJECT_ID=abcd
# Server-only (host secrets, never bundle)
SUPABASE_URL=https://abcd.supabase.co
SUPABASE_PUBLISHABLE_KEY=eyJhbGci...
SUPABASE_SERVICE_ROLE_KEY=eyJhbGci... # service role — keep secret
LOVABLE_API_KEY=lv_xxx # optional — only if AI features are used
BENEFITS_WEBHOOK_SECRET=<32-byte hex> # openssl rand -hex 32
REMINDERS_CRON_SECRET=<32-byte hex> # openssl rand -hex 32
EMAIL_FROM=hr@cityofx.gov
RESEND_API_KEY=re_xxx
Generate strong secrets with openssl rand -hex 32. Store all values in your password manager.
Did it work? ✅ bun run dev boots and you can sign up locally.
8. Deploy the app (pick one)
CivicPlexus is built on TanStack Start v1 with the Vite plugin. The build emits both a static client bundle and a server bundle — you always need an SSR runtime. Pure static hosts (plain S3, Netlify static, GitHub Pages) will not work; see §8.D.
8.A Cloudflare Workers (recommended, no Lovable)
Fastest, cheapest, globally distributed. Use the nodejs_compat flag so server functions can use fs, crypto, Buffer, stream, etc. Avoid Node-only npm packages here (sharp, puppeteer, child_process); use Worker-compatible equivalents.
bun add -g wrangler && wrangler login- Build:
bun run build - Set secrets in Cloudflare (run each line):
for v in VITE_SUPABASE_URL VITE_SUPABASE_PUBLISHABLE_KEY VITE_SUPABASE_PROJECT_ID \ SUPABASE_URL SUPABASE_PUBLISHABLE_KEY SUPABASE_SERVICE_ROLE_KEY \ LOVABLE_API_KEY BENEFITS_WEBHOOK_SECRET REMINDERS_CRON_SECRET \ EMAIL_FROM RESEND_API_KEY; do wrangler secret put "$v"; done - Deploy:
wrangler deploy - In Cloudflare DNS, add a CNAME
hr→<your-worker>.workers.dev, then attach a Custom Domain in the Workers UI for automatic TLS.
Did it work? ✅ https://hr.cityofx.gov returns the sign-in page; wrangler tail shows healthy SSR logs.
8.B Generic Node host (Render / Fly.io / Railway)
Use this if you prefer a familiar PaaS or need Node-only npm packages.
Build locally to confirm:
bun run build. Output lands in.output/server/index.mjs.Add a start script to
package.json(one-time):"start": "node .output/server/index.mjs".Choose your host:
Render — add a
render.yamlat the repo root:services: - type: web name: civicplexus runtime: node buildCommand: bun install && bun run build startCommand: node .output/server/index.mjs envVars: - key: NODE_VERSION value: "20"Fly.io —
fly launch --no-deploy, then infly.toml:[build] builder = "paketobuildpacks/builder:base" [processes] app = "node .output/server/index.mjs" [[services]] internal_port = 3000 protocol = "tcp" [[services.ports]] port = 443 handlers = ["tls", "http"]Railway — connect the repo; Railway auto-detects Bun. Set the start command to
node .output/server/index.mjs. Add a custom domain in the Settings tab.In the host's dashboard, paste every variable from §7 as an environment variable.
Deploy. Logs live in the host's UI.
Did it work? ✅ The host's domain returns the sign-in page; deploy logs show no missing env vars.
8.C Docker on a VM
Use this for full control or air-gapped environments.
Dockerfile:
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile && bun run build
FROM node:20-alpine AS run
WORKDIR /app
COPY --from=build /app/.output ./.output
COPY --from=build /app/package.json ./
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", ".output/server/index.mjs"]
docker-compose.yml (with Caddy for TLS):
services:
app:
build: .
env_file: .env.local
restart: unless-stopped
caddy:
image: caddy:2
ports: ["80:80", "443:443"]
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
restart: unless-stopped
volumes:
caddy_data:
Caddyfile:
hr.cityofx.gov {
reverse_proxy app:3000
}
Bring it up: docker compose up -d --build.
Did it work? ✅ curl -I https://hr.cityofx.gov returns 200; docker compose logs app is clean.
8.D Why static hosting will NOT work
CivicPlexus needs an SSR runtime for loaders, server functions, and /api/* routes. Do not try to deploy to plain S3, Netlify static, Cloudflare Pages without Functions, or GitHub Pages — every authenticated page will fail to render and every API call will 404. If you want a managed static-style experience, use Cloudflare Pages with Functions (same Worker runtime as 8.A) or Netlify/Vercel with their SSR adapters.
9. Email
- In Resend (or your provider), add your sending domain and add the SPF / DKIM / DMARC DNS records they show you.
- Verify the domain.
- Set
EMAIL_FROMtohr@yourdomain.govandRESEND_API_KEYto the API key.
Did it work? ✅ Trigger any notification (e.g., create a hire request) — the email lands in your inbox within a minute.
10. Schedule recurring jobs
CivicPlexus exposes signed cron endpoints under /api/public/hooks/. The shared REMINDERS_CRON_SECRET from §7 must be sent as a bearer token.
| Endpoint | Schedule | Purpose |
|---|---|---|
/api/public/hooks/reminders |
every hour (top of hour) | Send overdue task / approval / certification reminders |
/api/public/hooks/reminders?job=cert-expiry |
daily 07:00 local | Cert-expiry digest to managers |
/api/public/hooks/reminders?job=retention-sweep |
daily 02:00 local | Delete documents past retention |
Option A — pg_cron (Supabase Cloud):
select cron.schedule(
'civicplexus-reminders', '0 * * * *',
$$ select net.http_post(
url := 'https://hr.cityofx.gov/api/public/hooks/reminders',
headers := jsonb_build_object('Authorization', 'Bearer ' || current_setting('app.cron_secret'))
) $$
);
Then ALTER DATABASE postgres SET app.cron_secret = '<REMINDERS_CRON_SECRET>';.
Option B — generic crontab (any Linux host):
0 * * * * curl -fsS -H "Authorization: Bearer $REMINDERS_CRON_SECRET" https://hr.cityofx.gov/api/public/hooks/reminders
0 7 * * * curl -fsS -H "Authorization: Bearer $REMINDERS_CRON_SECRET" 'https://hr.cityofx.gov/api/public/hooks/reminders?job=cert-expiry'
0 2 * * * curl -fsS -H "Authorization: Bearer $REMINDERS_CRON_SECRET" 'https://hr.cityofx.gov/api/public/hooks/reminders?job=retention-sweep'
/api/public/hooks/benefits-callback is a webhook, not cron — the carrier calls it, signed with BENEFITS_WEBHOOK_SECRET.
Did it work? ✅ After one hour, the audit log shows cron_run events.
11. Create the first admin
- Sign up at
https://hr.cityofx.govwith your work email. - In Supabase SQL editor:
INSERT INTO public.user_roles (user_id, role) SELECT id, 'hr_admin' FROM auth.users WHERE email = 'you@city.gov'; - Sign out and back in.
Did it work? ✅ Sidebar shows People, Recruiting, Pay, Performance, Compliance, Admin groups.
12. Security hardening checklist
Tick every item before you let real employees in:
- HTTPS forced + HSTS ≥ 6 months on Cloudflare / your CDN.
- CSP header set in
src/routes/__root.tsxhead() (default-src 'self'; img-src 'self' data: https:; etc.). - MFA on every account (Supabase, host, registrar, email, GitHub).
- Supabase service-role key rotated after initial setup; only in your password manager and host secrets.
- Supabase Auth redirect URLs limited to your domain (no
localhost, no wildcards). - HIBP password check enabled.
- Cloudflare WAF managed ruleset ON (or equivalent on your host).
- Rate limiting on
/api/public/*(60 req/min/IP). -
attachSupabaseAuthis the only auth middleware insrc/start.ts(no duplicates, no replacements). - Every
/api/public/*route validates a signature or shared secret before doing work. -
user_rolesis read only viaprivate.has_role(...)in policies — never selected directly from client code. - Backups: Supabase Pro daily backups, 30-day retention. Test restore quarterly.
- Audit log reviewed weekly for first month, monthly thereafter.
- Access review every 90 days — remove stale admin accounts.
- Document retention cron active (daily sweep based on
org_settings.retention_policy). - Supabase linter clean: run
supabase db lintafter every migration. - Dependency scan monthly:
bun audit. -
.env.localin.gitignore(it already is — verify).
13. Compliance notes
| Requirement | How CivicPlexus helps | What you must do |
|---|---|---|
| I-9 §2 | Captures employee §1 + uploads | In-person verifier signs §2 within 3 business days |
| W-4 / state withholding | Form library + signature | Update annually when IRS releases the new form |
| FLSA exemption | FLSA Audit page; per-position duties test | Run a duties review whenever a position's responsibilities change |
| EEO-4 | EEO Snapshot as-of any date | File biennially with EEOC |
| State new-hire reporting | New Hire Reports queue + export | Upload to your state agency (TWC in Texas) within statutory window |
| TCOLE | Licence + continuing-ed tracking | Submit reporting cycles to TCOLE; investigate gaps |
| TMRS | Enrollment + monthly file | Upload monthly file to TMRS portal |
| FMLA | Case tracker with entitlement math | Issue WH-380/WH-381 forms; designate eligibility within 5 business days |
| CJIS | Encrypted storage, audit log, RBAC | Use a CJIS-eligible region (AWS GovCloud / Azure Gov); sign CJIS addendum with vendor |
| WOTC | Form fields + export | File IRS Form 8850 within 28 days |
| State retention | Per-kind retention in org_settings |
Set retention years per your state's records-retention schedule |
| SOC2 / NIST 800-53 | Audit log, RBAC, encryption | Inherit Supabase + Cloudflare's SOC2 reports; map controls |
| SSO / SCIM | SAML metadata field in Settings | Configure Entra ID or Okta enterprise app; contact support for SCIM activation |
14. Updating the app
git pull
bun install
supabase db push # if there are new migrations
bun run build
# then re-deploy via your chosen path in §8
Rollback: wrangler rollback (Workers), redeploy a previous image (Docker), or git checkout <prev-tag> then redeploy. For DB, restore the pre-update backup.
15. Monitoring & alerts
- Cloudflare Workers users: Cloudflare Analytics (requests, errors, WAF hits) +
wrangler tailfor live logs. - Node host users: the host's built-in log viewer (Render Logs, Fly
fly logs, Railway Logs). - Docker users:
docker compose logs -f app; ship logs to Loki/Datadog if you have one. - Supabase Logs: SQL errors, Auth errors, edge runtime — same across every deploy path.
- Sentry (free tier): add
@sentry/reactand your runtime's adapter with your DSN. /healthzendpoint: configure Better Uptime or UptimeRobot to ping every 5 min and SMS on failure.
16. Disaster recovery
| Metric | Target |
|---|---|
| RPO (max data loss) | 24 hours (daily backup) |
| RTO (max downtime) | 2 hours |
Restore drill (run quarterly):
- Spin up a fresh Supabase project.
- Download the latest backup from Supabase → Database → Backups.
- Restore via
psql < backup.sql. - Point a staging deployment at the restored DB. Sign in. Verify counts:
select count(*) from employees;matches production. - Document time-to-restore.
Secret rotation runbook:
- Generate a new value (
openssl rand -hex 32). - Set it on your host (
wrangler secret put, Render dashboard,fly secrets set, etc.). - Redeploy.
- If rotating
SUPABASE_SERVICE_ROLE_KEY: Supabase → Settings → API → Reset service_role. Update host secret. Redeploy.
17. Troubleshooting
| Error | Cause | Fix |
|---|---|---|
Missing Supabase environment variable(s) |
Env var not set on host | List your host's secrets; re-add missing |
| 401 on every server function | Bearer attacher not registered | Verify attachSupabaseAuth is in src/start.ts functionMiddleware |
permission denied for table X |
Missing GRANT in a migration | Add GRANT SELECT, INSERT, UPDATE, DELETE ON public.X TO authenticated; |
| Sign-in works but sidebar empty | No row in user_roles for the user |
Run the INSERT in §11 |
| Emails not arriving | Domain not verified | Re-check DNS in Resend |
| pg_cron job stuck | pg_net extension missing |
create extension pg_net; |
Unauthorized on /api/public/hooks/reminders |
Cron secret mismatch | Re-sync REMINDERS_CRON_SECRET host secret with app.cron_secret DB setting |
Build fails citing a src/server/* import |
Client bundle tried to import server-only code | Move logic into a *.functions.ts; never import *.server.ts from a route |
[unenv] X is not implemented yet! at runtime |
Node-only package on Workers (8.A) | Replace with a Worker-compatible alternative, or switch to 8.B/8.C |
Expected 3 parts in JWT; got 1 from PostgREST |
Using a sb_secret_* service key for public reads |
Use the publishable client for public read-only Data API calls |
| Build fails with "Unauthorized" during prerender | A public route's loader calls a protected server fn | Move the call into the component, or put the route under _authenticated/ |
18. Appendix
Full env var reference
| Name | Where | Description |
|---|---|---|
VITE_SUPABASE_URL |
Client + Server | Project URL |
VITE_SUPABASE_PUBLISHABLE_KEY |
Client + Server | Publishable (anon) key |
VITE_SUPABASE_PROJECT_ID |
Client | Project ref |
SUPABASE_URL |
Server only | Same as VITE version |
SUPABASE_PUBLISHABLE_KEY |
Server only | Same as VITE version |
SUPABASE_SERVICE_ROLE_KEY |
Server only | Secret — bypasses RLS |
LOVABLE_API_KEY |
Server only | Optional — Lovable AI Gateway, only if AI features are used |
BENEFITS_WEBHOOK_SECRET |
Server only | HMAC secret for benefits carrier callbacks |
REMINDERS_CRON_SECRET |
Server only + DB setting | Auth for cron → reminders endpoint |
EMAIL_FROM |
Server only | From-address |
RESEND_API_KEY |
Server only | Resend API key |
DNS records (example)
| Type | Name | Value | Purpose |
|---|---|---|---|
| CNAME | hr |
<your-worker>.workers.dev (or host's CNAME) |
App |
| TXT | @ |
v=spf1 include:_spf.resend.com -all |
SPF |
| TXT | resend._domainkey |
(from Resend) | DKIM |
| TXT | _dmarc |
v=DMARC1; p=quarantine; rua=mailto:dmarc@cityofx.gov |
DMARC |
Storage bucket policies
All six buckets in §5.8 are private. RLS lets a user read/write only their own folder (auth.uid()::text = (storage.foldername(name))[1]) except payroll-exports and compliance-exports, which are HR-only.
Cron jobs (default)
civicplexus-reminders— hourlycivicplexus-cert-expiry— daily 07:00 localcivicplexus-retention-sweep— daily 02:00 localcivicplexus-demo-reset— hourly (rolls back demo-user changes older than 24 hours)
Modules added since 1.0
These ship enabled by default and can be toggled per tenant from /admin-modules:
- Timekeeping — clock-in/out, OT, comp-time, exempt schedules
- Public Safety Shifts — 207(k) work periods, Kelly/Pitman/24-48 rotations
- HR + Finance — department FTE budgets and requisition headroom checks
- Payroll Profiles — Tyler Munis, BS&A, ADP, Paycom export templates
- Identity / SCIM — Entra/Okta SAML + SCIM v2 (
/api/public/scim.v2.Users) - CBA — union contract registry
- OSHA — 300/300A incident log and summary
- LMS Imports — external training ingest → TCOLE mapping. Manual CSV upload plus native connectors for Chamilo (REST v2), Moodle (Web Services REST), and Forma LMS (JSON API). Connections are stored in
lms_connections, runs inlms_sync_runs, both HR-admin RLS. Auto-sync is driven bypg_cronjoblms-auto-sync-hourlyhitting/api/public/hooks/lms-sync. Per-connection API tokens live as project secrets namedLMS_<connection_id>_TOKEN. - Cascades — org-wide directives with AI rollup
- 1-on-1 Templates — HR-managed question sets
- ID Badges + QR Verify — CR80 PDFs, public
/api/public/badge/{serial} - Offline PWA — service worker (
/sw.js) with Background Sync against/api/public/hooks/offline-replay
LiveKit (live interview rooms) {#livekit-live-interview-rooms}
Live multi-party interview rooms are powered by LiveKit. The rest of the Interviews module — scheduling, scorecards, candidate two-take async video responses — works without it. Enabling LiveKit only lights up the Join room action on each scheduled interview.
Steps:
- Create a project at cloud.livekit.io (free tier is sufficient for evaluation) or self-host LiveKit Server.
- From the project page, copy the WebSocket URL (
wss://your-project.livekit.cloud), the API Key, and the API Secret. - Save them as three backend secrets — names must match exactly:
LIVEKIT_URLLIVEKIT_API_KEYLIVEKIT_API_SECRET
- Reload an interview detail page. HR admins will no longer see the Enable live interview rooms banner, and panelists can click Join room.
- (Optional) To enable composite recordings, configure S3-compatible egress on the LiveKit project. Rooms function without egress; only the recording side is skipped, while the consent log and scorecards remain in place.
Did it work? ✅ On /interviews/<id>/room, the setup banner is gone and clicking Join interview opens the LiveKit room without an error toast.
Common failures:
Live interviews are not configured…toast → one of the three secrets is missing or misnamed.- Token mints but the room never connects →
LIVEKIT_URLishttps://…instead ofwss://…, or the project region is unreachable from the SSR runtime. invalid api keyfrom LiveKit → secret was rotated in the LiveKit dashboard; updateLIVEKIT_API_SECRETto match.
PWA / Background Sync deployment notes
- The build emits
/sw.jsviavite-plugin-pwaininjectManifestmode (source:src/sw.ts). /api/public/hooks/offline-replayverifies the user's Supabase bearer token before executing queued RPCs — do not place it behind additional auth middleware.- iOS Safari does not implement Background Sync; the worker falls back to a
drain-queuepostMessagewhile at least one tab is open. Plan field training accordingly. vite.config.tsdenylists/~oauth,/api/, and/authfrom the service worker navigation handler. Add any additional auth callback paths to the denylist if you extend the auth flow.
Cost worksheet
| Item | Monthly |
|---|---|
| Supabase Pro | $25 |
| Cloudflare Workers Paid (or Render Starter) | $5–$20 |
| Resend (50K emails) | $20 |
| Domain (amortized) | $1.25 |
| Total | ~$51–$66 |
You're done. Welcome new hires — and run payroll — with confidence.
Appendix — Installer, Health, Backup, Restore (v1.1)
CivicPlexus ships first-class shell scripts so a non-developer can install, back up, and restore without writing SQL or Bash.
One-command install
cp .env.example .env # fill in SUPABASE_* and AI_PROVIDER
./scripts/install.sh # verifies prereqs, applies migrations, builds
install.sh requires bun, psql, and the Supabase CLI (npm i -g supabase).
Re-running it is safe — migrations are idempotent.
First HR admin
psql "$SUPABASE_DB_URL" -f scripts/seed-first-admin.sql -v email=you@city.gov
The target user must have already signed up once (so an auth.users row exists).
Health probe
GET /api/public/health returns JSON with database + AI status; 200 when
healthy, 503 when degraded. Point your uptime monitor at it.
curl https://hr.yourcity.gov/api/public/health
Nightly backup
BACKUP_DIR=/var/backups/civicplexus ./scripts/backup.sh
Produces db.sql.gz, schema.sql, storage bucket copies, and SHA256SUMS.
Retains the last 30 nightly runs. Schedule via cron:
15 2 * * * cd /opt/civicplexus && BACKUP_DIR=/var/backups/civicplexus ./scripts/backup.sh >> /var/log/civicplexus-backup.log 2>&1
Restore
./scripts/restore.sh /var/backups/civicplexus/20260101T021500Z
Verifies SHA-256 checksums, asks for explicit confirmation, then restores.
Run a smoke test (/api/public/health + sign-in) before reopening to users.
RLS guardrail (CI)
psql "$SUPABASE_DB_URL" -v ON_ERROR_STOP=1 -f scripts/check-rls.sql
Fails if any public.* table lacks RLS or has zero policies — wire it into
your CI pipeline so a future migration cannot ship a table without policies.
AI provider pluggability
Set AI_PROVIDER in .env:
| Value | Required vars |
|---|---|
lovable |
LOVABLE_API_KEY (default for Lovable-hosted) |
openai |
OPENAI_API_KEY |
azure |
AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_API_KEY |
none |
Disables AI features (screening, summaries, etc.) |
The health endpoint reports ai_not_configured when the chosen provider is
missing credentials, so misconfiguration surfaces immediately.
15. Governance (Elected Officials & Boards)
Municipal governance data lives alongside HR but has its own tables, buckets, roles, and cron jobs.
15.1 Tables
official_bodies, official_seats, officials, official_terms, official_meetings, official_attendance, official_disclosures, officials_reimbursements. All are RLS-enabled.
15.2 Storage buckets
Both private:
official-photos— headshots for the public roster (served via signed URLs).official-documents— oaths, disclosures, appointment letters (clerk/HR only).
15.3 Roles
Add to your app_role enum if not present:
city_clerk— full CRUD on governance tables and roster exports.elected_official— read-only on own record and term; may submit own disclosures.
HR admins retain full override via hr_admin.
15.4 Public roster privacy flags
Per-record flags on officials:
public_visible(bool, defaultfalse) — required for the record to appear on/officialsand in the public CSV/PDF exports.public_show_contact(bool, defaultfalse) — email/phone shown publicly only whentrue.
The officials_public_read policy and exportPublicRosterCsv server function both enforce these flags. Set defaults per body when onboarding a new commission.
15.5 Scheduled jobs
Register in pg_cron (or the equivalent scheduler):
| Job | Cadence | What it does |
|---|---|---|
notify_officials_expiring |
daily 06:00 | Notifies HR admins and city clerks about active terms ending within 120 days (dedup 30 d). |
generate_meeting_reimbursements |
on demand | Called from the admin UI to convert attendance into draft reimbursement records. |
Term status changes to resigned or removed fire an immediate notification via officials_notify_targets() — no cron needed.
15.6 Health check
Governance is reported under modules.governance in the health probe. If storage buckets are missing, the probe returns governance_storage_missing.