Production-ready school theater ticketing platform with reserved seating, Stripe checkout, ticket delivery, and admin management.
- Frontend: React + Vite + TypeScript (
/src) - Backend: Fastify + Prisma + Postgres + TypeScript (
/backend) - Payments: Stripe Checkout + Stripe webhooks
- Email: SMTP (Nodemailer)
/backend is the only maintained API service. The root server.ts entrypoint and /apps/api have been intentionally disabled so hotfixes and deploys land on a single backend implementation.
- Performance browsing and seat map availability
- Hold sync endpoint with TTL and client token (
POST /api/hold) - Stripe checkout (
POST /api/checkout) - Webhook-based purchase finalization (
POST /api/webhooks/stripe) - Ticket links with QR payloads (
/tickets/:publicId) - Admin portal (
/admin) for:- dashboard
- performance CRUD + pricing tiers
- seat blocking/unblocking
- order search/detail/resend/refund
- attendee roster (no check-in scanning)
- audit log
npm installnpm --prefix backend installcp backend/.env.example backend/.envFill in:
DATABASE_URLSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETJWT_SECRETADMIN_USERNAMEADMIN_PASSWORD- SMTP settings (optional but recommended for email delivery)
npm --prefix backend run prisma:generate
npm --prefix backend run prisma:migrate -- --name init_ticketing
npm --prefix backend run seedTerminal A:
npm run devTerminal B:
npm run dev:backendOr one command:
npm run dev:fullFrontend: http://localhost:5173
Backend: http://localhost:4000
stripe listen --forward-to localhost:4000/api/webhooks/stripeUse the returned webhook signing secret as STRIPE_WEBHOOK_SECRET.
Frontend build:
npm run buildBackend typecheck:
npm --prefix backend run buildRelease expired holds manually:
npm --prefix backend run cron:release-holdsDatabase backup:
npm run backup:db -- --dry-runDatabase restore:
npm run restore:db -- --file backups/postgres/<file>.dump.enc --yes-i-understandHard reset all system data except users:
RESET_CONFIRM=WIPE_NON_USER_DATA npm --prefix backend run reset:system:keep-users -- --yesSafer restore into a separate database first:
npm run restore:db -- \
--file backups/postgres/<file>.dump.enc \
--target-db-url "postgresql://USER:PASSWORD@HOST:5432/theater_restore" \
--yes-i-understandSee:
docs/database-backups.mddeploy/systemd/theater-db-backup.servicedeploy/systemd/theater-db-backup.timer
See:
backend/docs/endpoints.md
VITE_API_BASE_URLoptional, defaults to same-originVITE_API_PROXY_TARGEToptional, defaulthttp://localhost:4000VITE_ALLOWED_HOSTSoptional, comma-separated host allowlist for tunneling the Vite dev server; defaults to.trycloudflare.comVITE_SITE_URLrecommended, used for canonical URLs, sitemap.xml, robots.txt, and social metadata
PORTTRUST_PROXY_HOPS(optional, default1in production and0otherwise)DATABASE_URLAPP_BASE_URLFRONTEND_ORIGINSTRIPE_SECRET_KEYSTRIPE_WEBHOOK_SECRETJWT_SECRETADMIN_USERNAMEADMIN_PASSWORDHOLD_TTL_MINUTESHOLD_CLEANUP_INTERVAL_SECONDSSMTP_HOST,SMTP_PORT,SMTP_USER,SMTP_PASS,SMTP_FROMGOOGLE_CALENDAR_ICS_URL(optional)R2_ACCOUNT_ID(optional ifR2_ENDPOINTis set)R2_ENDPOINT(optional ifR2_ACCOUNT_IDis set)R2_BUCKETR2_ACCESS_KEY_IDR2_SECRET_ACCESS_KEYR2_PUBLIC_BASE_URLR2_UPLOAD_PREFIX(optional, defaultuploads)R2_MAX_UPLOAD_BYTES(optional, default8388608)
After configuring R2 vars in backend/.env, convert existing Base64-stored images in Postgres:
npm --prefix backend run images:migrate-r2This migrates:
show.posterUrlcastMember.photoUrl- image data URLs nested in
contentPage.content(About page editor content)
- Deploy frontend static assets and backend service separately.
- Provision Postgres and set
DATABASE_URL. - Run backend migrations on deploy:
npm --prefix backend run prisma:deploy
- Build backend JS once per deploy so PM2 runs compiled output:
npm --prefix backend run build
- Recommended one-command deploy (single backend + single tunnel):
Notes:
npm install -g pm2 cp cloudflared/config.example.yml cloudflared/config.yml # edit cloudflared/config.yml with your tunnel id, credentials path, and hostname ./scripts/deploy-theater.sh pm2 startup- This uses
ecosystem.single.cjs. - It keeps only
theater-backend+theater-tunnel. - Checkout queue and hold cleanup run in-process inside
theater-backend. - Script location:
scripts/deploy-theater.sh - Optional overrides:
BACKEND_PORT,CLOUDFLARED_CONFIG,API_HEALTH_URL.
- This uses
- Alternative multi-process deploy (separate checkout + hold-cleanup workers):
For a temporary quick tunnel instead of a named tunnel:
# optional: set checkout worker replicas (defaults to 2) export CHECKOUT_WORKER_INSTANCES=3 pm2 start ecosystem.config.cjs pm2 save
Process inspection/restart:pm2 start ecosystem.quick-tunnel.config.cjs pm2 save
pm2 status pm2 logs theater-backend pm2 logs theater-checkout-worker pm2 logs theater-hold-cleanup pm2 logs theater-tunnel pm2 logs theater-quick-tunnel pm2 restart theater-backend pm2 restart theater-checkout-worker pm2 restart theater-hold-cleanup pm2 restart theater-tunnel
- Create a named Cloudflare Tunnel so the backend URL is stable. Point it at
http://localhost:$PORT(for examplehttp://localhost:6000), then set the frontendVITE_API_BASE_URLto that hostname and set backendFRONTEND_ORIGIN/APP_BASE_URLto your Vercel frontend URL. Quick tunnels are fine for temporary testing:They are not a good PM2 target because the hostname changes when the process restarts. If you tunnel the Vite dev server instead of the backend, setcloudflared tunnel --url http://localhost:6000
VITE_ALLOWED_HOSTSso Vite accepts the tunnel hostname. - Configure Stripe webhook endpoint:
https://<backend-domain>/api/webhooks/stripe
- Set all backend env vars in your host.
- Keep queue and cleanup workers running separately from the API process:
theater-checkout-workercan run multiple instances (setCHECKOUT_WORKER_INSTANCES).theater-hold-cleanupshould run as a single scheduler process.- If needed, you can still run manual/scheduled cleanup with
npm --prefix backend run cron:release-holds.