Adds a dependency-free liveness endpoint (GET /api/health), a Docker healthcheck against it, and docker/rolling-deploy.sh which rebuilds the shared image once and restarts pawfeed-1/2/3 sequentially, gated on each becoming healthy before moving to the next. Verified zero-downtime via continuous curl monitoring through a full run against production.
24 KiB
PawFeed
A social platform where pets are the stars. Dogs, cats, and birds get their own profiles — owners manage them, but the pet is the identity. Think Instagram built from the ground up for animals: pets follow each other, post photos and videos, celebrate milestones, and track their health, all within a community that actually cares about animals.
Core value: Pets are first-class social identities — not just content on a human's feed — so every feature is built around the pet's life, not the owner's.
Status: Live in production at pawfeed.org (public, invite gate disabled since 2026-08-10).
Tech Stack
| Layer | Technology | Version |
|---|---|---|
| Framework | Next.js | 16.x |
| UI | React + Tailwind CSS v4 + shadcn/ui (base-nova preset) | React 19 |
| Language | TypeScript | 6.x |
| API | tRPC | 11.x |
| ORM | Prisma | 7.x |
| Database | PostgreSQL via Supabase (managed) | 16 |
| Auth | Clerk (+ @clerk/themes for dark-mode-aware Clerk UI) |
7.x |
| Media Storage | Supabase Storage (S3-compatible, presigned uploads) | — |
| Cache / Feed / Rate Limiting | Self-hosted Redis (ioredis, Docker service — not Upstash) |
5.x |
| Client Fetching | TanStack Query | 5.x |
| Video | Mux (upload, transcoding, HLS streaming) | — |
| i18n | next-intl (EN/DE message catalogs; legal pages fixed-German) | 4.x |
| Theming | next-themes (account-bound dark mode via Owner.themePreference) |
— |
| Forms / Validation | react-hook-form + @hookform/resolvers + Zod |
— |
| Nodemailer (branded HTML transactional mail) | 9.x | |
| Error Monitoring / Tracing | @sentry/nextjs SDK → self-hosted GlitchTip (glitchtip.pawfeed.org) |
10.x |
| Tests | Vitest | 4.x |
| Hosting | Self-hosted Docker on an OpenMediaVault NAS (behind Nginx Proxy Manager + Cloudflare) — not Vercel | — |
Design decisions:
- All data access goes through Prisma → direct Postgres connection, never through Supabase PostgREST. RLS is enabled on all tables with no policies, which closes the PostgREST surface entirely.
- Media (photos, avatars, stories) is stored in Supabase Storage via presigned PUT URLs — file bytes never pass through the Next.js API server.
- The social feed uses a Redis fan-out-on-write pattern: on each new post, a sorted set entry is written into every follower's feed inbox.
feed.getFeedreads from Redis and hydrates with Prisma. Falls back to Postgres when Redis is unavailable. - Authentication is handled by Clerk (owner accounts). Pet profiles are app-domain entities in Postgres, not Clerk users.
- Short-form video is handled by Mux: client uploads directly to a signed Mux URL → Mux transcodes and serves HLS via
mux-player-react. ThegetByPostIdquery self-heals in dev by polling the Mux API directly, so webhooks are not required during local development. - Health data is strictly owner-private. The only publicly accessible health surface is the shareable health card (token URL with expiry), designed for vets, friends, and pet sitters. The emergency vet contact is intentionally excluded from the shared card — it lives only in the private dashboard.
- Deployment is self-hosted, not Vercel.
next.config.tsstill setsoutput: "standalone", but the app ships as a Docker image (docker/Dockerfile,docker/docker-compose.yml) to a NAS, run as 3 load-balanced replicas (not 1 container, since 2026-08-16 — Next.js standalone is single-process, one container = one CPU core)../start.shrebuilds the image and recreates all 3 replicas. - Content-Security-Policy runs in Report-Only mode (
src/proxy.ts, via Clerk's owncontentSecurityPolicymiddleware option) while directive gaps get closed from real violation reports collected in GlitchTip, before switching to enforced. - Admin/moderation panel lives at a secret path (
/p/[ADMIN_SECRET]/...) — wrong or missing secret returns a bare 404, not 403, so the panel's existence isn't revealed to unauthenticated probing. Role-gated at the tRPC layer on top of that (SUPER_ADMIN/MODERATOR).
Project Structure
src/
app/
(app)/ # Protected app shell (Clerk auth via src/proxy.ts)
feed/ # Home feed page + StoryTray
notifications/ # Notification center page
pets/ # Pet list ("my pets" switcher)
pets/[petId]/ # Pet profile page + ProfileTabs
pets/[petId]/edit/
pets/[petId]/followers/
pets/[petId]/following/
pets/[petId]/health/ # Owner-only health dashboard (weight, vet visits, vaccines, emergency vet)
explore/ # Species/breed-filtered discovery feed
search/ # Pet + People + Hashtag search
hashtag/[tag]/ # Hashtag feed
messages/ # DM inbox list
messages/[conversationId]/
invite/ # In-app "invite a friend" surface
onboarding/pet/ # First-pet creation onboarding
(auth)/ # Clerk-rendered sign-in / sign-up / password reset / email verify
p/[secret]/ # Admin & moderation panel — path segment IS the secret (ADMIN_SECRET)
users/ moderators/ posts/ reports/ ads/ invites/ verification/ blacklist/ messages/ log/ legal/
health-card/[token]/ # Public shareable health card (no login required)
join/ # Invite-code landing page, sets the pf_invite cookie
banned/ # Shown to banned owners (proxy.ts redirect target)
impressum/ datenschutz/ nutzungsbedingungen/ # German legal pages, fixed-language, not under (app)
api/
trpc/[trpc]/ # tRPC HTTP handler
auth/
set-invite/ consume-invite/ # Invite-cookie lifecycle around sign-up
account/delete/ # Self-service account deletion
gdpr-export/[token]/ # Tokenized personal-data export (GDPR)
csp-report/ # Receives CSP Report-Only violations, forwards to GlitchTip
cron/
anniversaries/ # Birthday + adoption-day notifications, daily 08:00
trim-feeds/ # Trims each pet's Redis feed sorted set to a 90-day window
webhooks/
mux/ # Mux webhook handler (video.asset.ready etc.)
components/ # feed/ health/ layout/ notifications/ pet/ post-creation/
# profile/ safety/ social/ stories/ video/ content/ (AI-disclosure UI)
# providers/ (ThemeProvider, ClerkThemeProvider) ui/ (shadcn copies)
context/
ActivePetContext.tsx # Global active pet state (pet switcher), SSR-initialized
i18n/
request.ts # next-intl config — check here before assuming a routing scheme
lib/
supabase-storage.ts # Supabase Storage presigned URL helpers (server-only)
redis.ts # ioredis client (self-hosted, lazyConnect)
rate-limit.ts # Redis-backed rate limiting middleware
mail.ts / mail-template.ts # Nodemailer + branded HTML email template
mux.ts # Mux Node SDK singleton
assert-pet-ownership.ts # Shared pet-ownership check used across routers
timing-safe-compare.ts # Constant-time string compare for admin/cron secrets
invite-codes.ts # InviteCode generation (word-prefix + 4-char code)
trpc/
init.ts # tRPC context ({ userId, prisma }); protectedProcedure
server.ts # Server-side caller for RSC prefetching
query-client.ts
routers/ # ~24 domain routers aggregated in _app.ts:
# pets, posts, stories, milestones, follows, blocks, reports,
# feed, reactions, comments, reposts, explore, search,
# notifications, videos, messages, health, admin, invites,
# ads, owner, legal
__tests__/ # One file per domain router; Prisma fully mocked (prisma-mock.ts)
proxy.ts # Next.js middleware (renamed from middleware.ts in Next 16) —
# invite gate + admin-secret gate + Clerk auth.protect() + CSP headers
prisma/
schema.prisma # Full DB schema — see Schema section below
seed.ts # Species + breed seed (Dog/Cat/Bird), with translations
supabase/
migrations/ # RLS-enablement SQL (Prisma bypasses RLS via service_role)
docker/
Dockerfile / docker-compose.yml / start.sh # Self-hosted deployment
Prisma Schema — Model Overview
Owner — Clerk userId, owns n Pets; themePreference (dark mode)
Pet — Social identity: name, bio, adoptionStory, avatarKey, dmPolicy,
birthday, adoptedAt, species, breed, aiDisclosure
Species / Breed — Taxonomy, each with a *Translation table (i18n, not per-language rows)
Post / PostImage — PHOTO | MILESTONE | REPOST | VIDEO; denormalized reaction/comment/repost counts
Milestone — 1:1 with a Post (MilestoneType enum)
Story / StoryView — 24h ephemeral photos; views retained 30 days
Follow / Block — Pet ↔ Pet social graph (no owner columns)
Report — Post or Pet report (ReportReason enum), unique-constrained against spam
Reaction / Comment / Repost
Hashtag / PostHashtag
VideoPost — Mux upload/asset/playback IDs + VideoStatus
Conversation / Message — Pet ↔ Pet DMs; petAId always < petBId
WeightLog / VetVisit / Vaccine / EmergencyVet / HealthCard — owner-private health data
DataExportToken — GDPR export token lifecycle
Notification — FOLLOW | REACTION | COMMENT | MESSAGE | BIRTHDAY | ADOPTION_DAY
InviteCode — Beta invite-gate codes (word-prefix + 4-char code)
AdminRole — SUPER_ADMIN | MODERATOR, layered on top of ADMIN_OWNER_ID bootstrap
VerificationRequest — "verified pet" application flow with a required checklist
Warning / UserBan / Shadowban / BlacklistEntry — moderation actions
Advertisement / AdReaction / AdComment / AdRepost — sponsored posts with species/breed targeting
ModerationLog — audit trail for every admin/moderator action
LegalDocumentVersion — versioned legal-doc content + update-banner trigger
Setup
Prerequisites
- Node.js 20+
- A Supabase project (PostgreSQL + Storage)
- A Clerk application
- A Redis instance (self-hosted, e.g.
docker run redis— the app talks to it viaREDIS_URL; no Upstash-specific code) - A Mux account (for video posts)
- Optional: a GlitchTip instance (self-hosted or hosted) or Sentry project, for error monitoring
1. Clone and install
git clone <repo-url>
cd pawfeed
npm install
2. Environment variables
Copy .env.example to .env.local and fill in all values — it documents every variable, including the optional GlitchTip/Sentry ones (SENTRY_DSN, NEXT_PUBLIC_SENTRY_DSN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN) and the invite gate (INVITE_REQUIRED).
cp .env.example .env.local
Key variables (see .env.example for the full annotated list):
| Variable | Where to find it |
|---|---|
DATABASE_URL |
Supabase -> Project Settings -> Database -> Connection string (pooled, port 6543) |
DIRECT_URL |
Supabase -> Project Settings -> Database -> Connection string (direct, port 5432) |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY / CLERK_SECRET_KEY |
Clerk Dashboard -> API Keys |
NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY |
Supabase -> Project Settings -> API |
REDIS_URL |
Your Redis instance connection string (e.g. redis://:password@localhost:6379) |
MUX_TOKEN_ID / MUX_TOKEN_SECRET |
Mux Dashboard -> Settings -> API Access Tokens |
MUX_WEBHOOK_SECRET |
Mux Dashboard -> Settings -> Webhooks -> Signing secret |
CRON_SECRET |
Generate a random string; used by both cron routes |
ADMIN_SECRET |
UUID that gates the /p/[secret]/ admin panel path |
ADMIN_OWNER_ID |
Clerk user ID bootstrapped as SUPER_ADMIN |
SUPABASE_SERVICE_ROLE_KEYandMUX_TOKEN_SECRETare server-only secrets — never exposed to the client.
3. Database setup
Push the Prisma schema to Supabase (this project uses db push, not migration files — local dev and production share one database, so a migration drift/reset would be destructive):
npx prisma db push
npx prisma generate
Seed species and breeds:
npx prisma db seed
Enable Row Level Security on all tables (run once in Supabase SQL Editor):
-- File: supabase/migrations/20260613000000_enable_rls_all_tables.sql
-- Paste the contents of this file into the Supabase SQL Editor and run.
This closes the PostgREST surface. Prisma (direct connection with service_role) is unaffected.
4. Supabase Storage
Create a bucket named pet-avatars in Supabase Storage (Dashboard -> Storage -> New bucket), with fileSizeLimit/allowedMimeTypes restricted appropriately.
5. Mux webhook (production only)
In Mux Dashboard -> Settings -> Webhooks, add an endpoint pointing to https://your-domain.com/api/webhooks/mux. Subscribe to:
video.upload.asset_createdvideo.asset.readyvideo.asset.errored
In local development, the webhook is not needed.
videos.getByPostIdpolls the Mux API directly when the video is stillPROCESSING, so the status self-heals without a tunnel.
6. Cron jobs (production only)
There is no serverless cron here — vercel.json's crons array is inert on a self-hosted deployment. Trigger the two cron routes externally (e.g. a NAS-side crontab hitting them with a bearer token):
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/anniversaries # daily 08:00
curl -H "Authorization: Bearer $CRON_SECRET" https://your-domain.com/api/cron/trim-feeds # daily
7. Run the dev server
npm run dev
Open http://localhost:3000. The app redirects unauthenticated users to Clerk sign-in.
8. Docker deployment (production)
cd docker
cp .env.example .env # fill in production values, not committed to git
docker compose build
./start.sh
docker-compose.yml passes the NEXT_PUBLIC_* vars and the Sentry/GlitchTip build-time vars (SENTRY_ORG, SENTRY_PROJECT, SENTRY_AUTH_TOKEN) as Docker build args — everything else is injected at runtime via env_file.
As of 2026-08-16 this runs as 3 replicas (pawfeed-1/2/3), not 1 container — Next.js standalone output is single-process, so a single container can't use more than one CPU core; there's no single-container path anymore. Nginx Proxy Manager load-balances across them via a least_conn upstream (pawfeed_upstream) — see docker/SCALING-ROADMAP.md for the full rationale and the NPM-side config location. For deploys against the live site, run ./docker/rolling-deploy.sh instead of docker compose up -d --build — it rebuilds the image once and restarts the 3 replicas one at a time (gated on GET /api/health), verified zero-downtime. docker compose up -d --build still works but restarts all 3 at once.
Key Commands
| Command | What it does |
|---|---|
npm run dev |
Start Next.js dev server (Turbopack) |
npm run build |
Production build (output: "standalone") |
npm run lint |
ESLint (flat config) |
npx vitest run |
Run full test suite |
npx vitest run src/__tests__/posts.test.ts |
Run a single test file |
npx tsc --noEmit |
Type check without emitting |
npx prisma db push |
Sync schema to DB (no migration files) |
npx prisma generate |
Regenerate Prisma client after schema change |
npx prisma studio |
Open Prisma Studio (DB browser) |
npx prisma db seed |
Re-seed species + breeds |
Architecture Notes
Identity model
Clerk User (owner)
└── owns 1..n Pet profiles (Postgres)
└── Pet is the social identity (posts, follows, stories, milestones,
reactions, comments, reposts, notifications, videos, DMs, health data)
An owner can switch between their pets via ActivePetContext. All tRPC mutations assert pet.ownerId === ctx.userId before writing (via the shared assertPetOwnership() helper).
Middleware — src/proxy.ts, not middleware.ts
Next.js 16 renamed middleware.ts to proxy.ts — this is the middleware. It layers four concerns in one Clerk middleware: (1) invite gate — blocks /sign-up unless a pf_invite cookie is present, unless INVITE_REQUIRED=false; (2) admin secret gate — 404s /p/* unless the path segment matches ADMIN_SECRET; (3) auth.protect() for everything not in the public route matcher; (4) Report-Only Content-Security-Policy headers via Clerk's contentSecurityPolicy option.
Media pipeline — Photos
Client -> tRPC getPresignedUrl -> Supabase Storage signed URL
Client -> XHR PUT directly to signed URL (bytes never hit Next.js server)
Client -> tRPC create/confirmUpload (writes storage key to DB)
Feed -> getMediaUrl(storageKey) -> Supabase public CDN URL
Media pipeline — Video (Mux)
Client -> tRPC videos.createUpload -> Mux direct upload URL + PROCESSING Post row
Client -> XHR PUT directly to Mux URL (bytes never hit Next.js server)
Mux -> transcodes -> fires webhook -> /api/webhooks/mux -> updates VideoPost (READY + playbackId)
VideoCard polls videos.getByPostId every 5s -> transitions from spinner to MuxPlayer
In dev (no webhook):
getByPostIdchecks the Mux API directly when the status isPROCESSINGand updates the DB on the fly. No tunnel required.
Feed architecture
Post created
└── fanOutPost(redis, postId, petId, createdAt, prisma)
└── for each follower: ZADD feed:{followerPetId} score=timestamp member=postId
feed.getFeed({ petId, cursor, mode: "chrono" | "algo" })
└── ZRANGE feed:{petId} -> postIds
└── prisma.post.findMany({ where: { id: { in: postIds } } }) -> hydrated posts
└── chrono mode: preserves Redis sorted-set order (newest first)
└── algo mode: client-side re-sort by algoScore()
algoScore = reactions×3 + comments×5 + originalReposts×4 + recency bonus
└── Fallback (Redis unavailable): Postgres query on follows + own posts
A daily cron (/api/cron/trim-feeds) trims each pet's Redis feed sorted set to a 90-day retention window.
Admin & moderation panel
Gated by path secret (ADMIN_SECRET, see proxy.ts) and by role at the tRPC layer: admin.ts's assertAdmin() allows either ADMIN_OWNER_ID (env-bootstrapped super-admin) or an AdminRole row (SUPER_ADMIN / MODERATOR). Every action is written to ModerationLog. Covers users, moderators, posts, reports, ads, invites, verification requests, and blacklist entries.
Invite system
InviteCode (word-prefix + 4-char code, e.g. PAWS-7K3M) gates sign-up via a pf_invite cookie set by /join and consumed by /api/auth/consume-invite right after sign-up. INVITE_REQUIRED=false turns the whole gate off (the current production setting, since 2026-08-10).
Error monitoring & CSP
@sentry/nextjs reports both errors and performance traces to a self-hosted GlitchTip instance. CSP currently runs in reportOnly mode; violations land at /api/csp-report, get forwarded to GlitchTip, and directives get tightened as real traffic surfaces gaps — before switching to enforced.
Health data privacy
All health data (weight logs, vet visits, vaccines, emergency vet) is owner-private:
/pets/[petId]/healthredirects any non-owner to the public profile- All tRPC health procedures call
assertOwner()before any DB access - The only public surface is the HealthCard at
/health-card/[token](UUID token, user-selected expiry, PDF export via?print=1) - Does NOT show: emergency vet contact (stays off the shared card)
Notification triggers (fire-and-forget)
follows.follow -> FOLLOW notification to followeePet
reactions.toggle -> REACTION notification to post owner (skip if own post)
comments.create -> COMMENT notification to post owner (skip if own post)
/api/cron/anniversaries (daily 08:00)
-> BIRTHDAY / ADOPTION_DAY notification, 1-year minimum guard
All in-app triggers are wrapped in .catch(() => {}) — they never crash the parent mutation.
PawRing (story indicator)
Instead of a standard circular ring, active stories are indicated by a paw-print SVG outline around the avatar: orange (unseen), gray (seen), no ring (none). Identity comes from ActivePetContext inside PawRing itself.
What's Shipped
Foundation → content core → engagement → notifications → video → messaging → health tracking are all live, plus a full production layer built after MVP:
- i18n — next-intl EN/DE, species/breed translation tables (legal pages stay fixed-German)
- Dark mode — account-bound (
Owner.themePreference), synced across Clerk's own UI - Admin/moderation panel — users, posts, reports, ads, invites, verification, audit log, 3-tier role matrix
- Invite system — beta gate, now disabled for public launch
- Legal — Impressum/Datenschutz/AGB, versioned with an update-banner trigger, AI-content disclosure badges on uploads
- GDPR — tokenized personal-data export, self-service account deletion
- Ads — sponsored posts with species/breed targeting, ad-specific reactions/comments/reposts
- Security hardening — Redis-backed rate limiting, CSP (Report-Only), HSTS/security headers, constant-time secret comparison, self-hosted error monitoring
- Self-hosted deployment — Docker on an OpenMediaVault NAS behind Nginx Proxy Manager + Cloudflare
Open / next up
| Item | Notes |
|---|---|
| CSP enforced mode | Waiting on a clean Report-Only period in GlitchTip before flipping reportOnly: false |
| Story video support | Stories currently photo-only; StoryViewer already has a progress timer, needs video playback |
| Follower milestone auto-notifications | MilestoneType has FOLLOWERS_500 etc. but no trigger wired yet |
| Google / Apple OAuth | Supported natively by Clerk — deliberately deferred, no code change required |
| Saved posts | Bookmarking to a private collection — no schema yet |
| Comment replies | Flat comments only today; 1-level threading would need a replyToId FK |
| React Native app | Web-first for v1, listed as a future milestone |
| Supabase Realtime | Replace polling (NotificationBell, conversation view) with channel subscriptions |
Testing
Tests are unit tests only (no DB, no network). Prisma is fully mocked per-model via createMockPrisma() in src/__tests__/helpers/prisma-mock.ts.
npx vitest run # full suite
npx vitest # watch mode
Known Issues / Debugging Notes
| Issue | Notes |
|---|---|
DATABASE_URL must use the pooled connection (port 6543, ?pgbouncer=true) |
Prisma uses this at runtime. DIRECT_URL (port 5432) is only for prisma db push. |
Prisma schema changes always use db push, never migrate dev/reset |
Local dev and production share a single Supabase DB — a migration drift/reset would delete production data. |
| After a Prisma schema change, always restart the dev server | The Next.js dev server caches the Prisma client in memory. |
| Supabase service_role key is never client-facing | All files that import supabase-storage.ts must be server-only. |
asChild prop is not available in the base-nova shadcn preset |
Style the trigger element directly instead. |
| Local dev cannot exercise authenticated routes | Clerk runs with production keys and rejects non-pawfeed.org origins; only public pages are testable against npm run dev. |
| Mux webhook not required in local dev | videos.getByPostId polls Mux directly when status is PROCESSING. |
CRON_SECRET must be set for both cron routes |
Without it, the routes return 401 and nothing fires — there is no serverless scheduler here, an external trigger (NAS crontab) is required. |