# Deploying to production (Railway + Vercel)

Last updated Oct 26, 2026 · Page: https://freebeeads.com/docs/repo/deploy · Markdown: https://freebeeads.com/docs/md/repo/deploy.md · All docs: https://freebeeads.com/llms-full.txt

Two services from one GitHub repo (`ownagentlabs-tech/dooh-platform`).

## 1. Backend → Railway

1. Railway → New Project → **Deploy from GitHub repo** → pick `dooh-platform`.
2. Service settings → **Root Directory: `backend`** (Procfile runs `alembic upgrade head && uvicorn … --port $PORT`).
3. Add **PostgreSQL** to the project; Railway injects `DATABASE_URL` — confirm the service sees it
   (postgres:// URLs work; SQLAlchemy accepts them via psycopg2).
4. Add a **Volume** mounted at `/data` on the backend service.
5. Service → Variables:
   - `ENVIRONMENT=production`
   - `SECRET_KEY=<openssl rand -hex 32>`
   - `OPS_PASSWORD=<strong password>` (ops user is auto-created on boot as ops@dooh.local)
   - WhatsApp delivery (wallet OTP + cricket code), once the AiSensy account is ready:
     `WHATSAPP_PROVIDER=aisensy`, `AISENSY_API_KEY=<from AiSensy → Manage → API key>`,
     and, only if you named the templates differently, `AISENSY_OTP_CAMPAIGN` /
     `AISENSY_CRICKET_CAMPAIGN` (defaults `freebee_otp` / `freebee_cricket_code`).
     Then remove `WALLET_OTP_DEV`. Until then leave `WHATSAPP_PROVIDER` unset (log).
   - AI name check on the wall (optional): `MODERATION_PROVIDER=anthropic` and
     `ANTHROPIC_API_KEY=<console.anthropic.com>`. Roughly ₹0.01 per new name; cached per name.
   - AI helpers for advertisers (optional, 2026-10-26): `AI_PROVIDER=anthropic` with the same
     `ANTHROPIC_API_KEY` switches on "Suggest with AI" (headline, slogan, prize titles) and the
     wrong-name guard in the game editor and the approvals panel. Unset (`off`) the buttons say
     "Not set up yet; our team can switch this on". Tuning (defaults are fine):
     `AI_MODEL=claude-haiku-4-5-20251001`, `AI_TIMEOUT_SECONDS=15`,
     `AI_CHECK_NAMES_TIMEOUT_SECONDS=4`, `AI_COPY_MAX_PER_BUSINESS_PER_HOUR=20`,
     `AI_CHECK_NAMES_MAX_PER_BUSINESS_PER_HOUR=300`, `AI_COPY_CACHE_HOURS=24`.
     `ANTHROPIC_API_URL` is only for tests or a local stub; leave it unset in production.
     Cost: a copy request is about 1k tokens on Haiku (well under ₹1), cached a day per identical
     request. Image enhance needs nothing (Pillow). "Make a short clip" needs ffmpeg (next section);
     without it the menu item says the server cannot render clips yet.
   - Billing: leave `PAYMENTS_PROVIDER` unset (manual) until there is a Razorpay account;
     invoices still issue and ops mark bank/cash payments by hand. Set `BILLING_SELLER_NAME`,
     `BILLING_SELLER_GSTIN`, `BILLING_SELLER_ADDRESS`, `BILLING_SELLER_STATE_CODE` and
     `BILLING_BANK_DETAILS` (details separated by ` | `) before the first real invoice. To
     switch online payment on: `PAYMENTS_PROVIDER=razorpay`, `RAZORPAY_KEY_ID`,
     `RAZORPAY_KEY_SECRET` (dashboard → Settings → API keys) and `RAZORPAY_WEBHOOK_SECRET`
     (dashboard → Webhooks → add `https://<api>/v1/webhooks/razorpay`, event
     `payment.captured`). Refunds are manual in the Razorpay dashboard for now.
   - Email to advertisers (password reset, team invites, notifications): `EMAIL_PROVIDER=resend`,
     `RESEND_API_KEY=<from Resend → API keys>`, `EMAIL_FROM=Freebee <hello@yourdomain.in>` (a sender
     verified in Resend) and `WEB_URL=https://<your-app>.vercel.app` (links in emails). Leave
     `EMAIL_PROVIDER` unset (log) until Resend is ready; never set `EMAIL_DEV_ECHO` in production
     (the backend refuses to boot). The daily messages and the Monday weekly report are sent by
     the built-in scheduler (below); no cron needed.
   - Built-in scheduler (2026-10-26): `SCHEDULER_ENABLED` (unset = on with `ENVIRONMENT=production`;
     `0` turns it off, `1` turns it on elsewhere). It runs inside the one uvicorn process: bidding
     close every 5 minutes, daily messages 06:30 IST, coupon reminders 10:00 IST, weekly report
     Mondays 08:00 IST. Ops see each job's last run on the Today page ("Background jobs", with Run
     now). Keep the API at ONE replica/worker: a second process would run a second scheduler (a DB
     lock is a TODO in `app/core/scheduler.py`). Remove any Railway cron that called the old
     run-daily / close-due / run-reminders endpoints (harmless, as they are idempotent, but
     redundant).
   - `CORS_ORIGINS=https://<your-app>.vercel.app` (comma-separated if more; the app REFUSES to boot
     in production with dev defaults or CORS "*" — that is intentional)
   - `MEDIA_STORE_DIR=/data/media`
   - `SENTRY_DSN=<backend project DSN>` (optional — error tracking; off when unset. Release =
     `RAILWAY_GIT_COMMIT_SHA`, environment = `ENVIRONMENT`; phones/OTPs are scrubbed. See `docs/UPTIME.md`)
6. Deploy → note the public URL, e.g. `https://dooh-backend-production.up.railway.app`.
7. Do NOT run `scripts/seed.py` in prod (it creates demo screens + demo advertiser). The ops user
   is created automatically. Add real venues/zones/screens through /ops in the UI.

## 2. Web → Vercel

1. Vercel → Add New Project → import the repo → **Root Directory: `web`**.
2. Environment variables (Production):
   - `NEXT_PUBLIC_API_URL=https://<railway-backend-url>`
   - `PLAYER_BACKEND_URL=https://<railway-backend-url>` (server-side, used by the /player proxy)
   - `NEXT_PUBLIC_SENTRY_DSN=<web project DSN>` (optional — browser + server error tracking; off when
     unset; no replay, no PII. See `docs/UPTIME.md`)
   - `NEXT_PUBLIC_POSTHOG_KEY=<project key>` + `NEXT_PUBLIC_POSTHOG_HOST=eu|us` (optional — product
     analytics on ourselves; off when unset; anonymous events only. See `docs/ANALYTICS.md`)
   - (never set `NEXT_PUBLIC_MOCK` — it is also hard-disabled in prod builds)
3. Deploy → `https://<your-app>.vercel.app`.
4. Update Railway's `CORS_ORIGINS` with the final Vercel URL if it changed.

## 3. Prod smoke test (10 minutes)

1. `https://<app>.vercel.app/ops` → log in ops@dooh.local + your OPS_PASSWORD.
2. /ops/screens → Add venue → zone → screen (your real first venue).
3. Any tablet/iPad → Safari → `https://<app>.vercel.app/player` (real cert — no warning) →
   pairing code appears → claim it in /ops/pair.
4. Signup as a test advertiser → upload creative → /ops approve → book today on that screen →
   watch the player pick it up within ~5 min (schedule refresh) → dashboard fills.
5. Vision: on the tablet, 5 taps in a corner → Vision ON → allow camera → watcher counts go live.

## Notes

- Media uploads live on the Railway volume; Postgres holds everything else. Enable Railway's
  Postgres backups.
- Signup is OPEN in this phase (anyone with the link can create an advertiser account) — fine for
  team testing; gate with invite codes before public launch.
- Player schedule refresh is 300s; creative approval + booking materialization are immediate.
- Local dev is unchanged: SQLite + localhost, `scripts/seed.py` for demo data.
- Bidding for busy windows (2026-10-24): `BIDDING_CLOSE_LEAD_HOURS` (default 48) sets when a round
  closes if ops leave the close time empty. The built-in scheduler closes due rounds every 5 minutes
  (2026-10-26); `POST /v1/ops/bidding/close-due` still works and is idempotent. Ops can also press
  "Close rounds that are due" on a screen's page.
- Mall partner (2026-10-26): migration `mall_partner`. `PARTNER_API_MAX_PER_MIN` (default 60)
  caps requests per partner API key per minute on `/v1/partner/*` (in-process, like the other
  throttles; `RATE_LIMITS_ENABLED=0` turns it off). `MALL_DEFAULT_REVENUE_SHARE_BP` (default 3000
  = 30%) is the share a NEW venue starts on; ops change it per venue. Mall logos live on the media
  volume beside creatives. Nothing pays malls automatically: ops record each monthly transfer with
  "Mark paid" on the venue's Mall partner panel.

## ffmpeg on Railway (server-side video conversion, 2026-10-24) — OFF until the founder switches it on

The backend converts `.mov/.webm/.mkv/.avi` and non-H.264 mp4 uploads to H.264/AAC mp4
(`app/services/transcode.py`) **only when `ffmpeg` and `ffprobe` are on the host**. The
Railway image built by Nixpacks has neither, so today those uploads are refused with
"Our server cannot convert this format yet; export as H.264 MP4", and the creative card's
Trim / Fit to screen menu items are disabled for video (images still work, via Pillow).

To switch it on, uncomment the block in `backend/nixpacks.toml`:

```toml
[phases.setup]
nixPkgs = ["...", "ffmpeg"]
```

(`"..."` keeps Nixpacks' auto-detected packages, python included.) Risks: build time grows by
roughly 60-90 s and the image by ~150 MB; each conversion is a few seconds of CPU on the API
dyno (uploads are capped at 50 MB / 10 s so one upload cannot pin it for long). Optional env:
`TRANSCODE_ENABLED=0` forces the old refuse-with-reason behaviour even with ffmpeg present;
`TRANSCODE_TIMEOUT_SECONDS` (default 180) aborts a stuck conversion with a clear 422.
The same switch turns on "Make a short clip from this image" (AI helpers, 2026-10-26): a 6 to 10 s
slow-zoom or pan H.264 clip rendered in 1 to 3 s per clip.

New env for prize fulfilment (defaults are fine): `PRIZE_MAX_PER_PHONE_PER_CAMPAIGN_DAY=2`,
`PRIZE_MAX_PER_PHONE_PER_DAY=5`, `COUPON_REMINDER_WINDOW_HOURS=24`,
`AISENSY_REMINDER_CAMPAIGN=freebee_coupon_reminder` (approve that template in AiSensy:
"{{1}} from {{2}} expires on {{3}} — show it at the counter before then"). The built-in scheduler
runs the reminders daily at 10:00 IST (2026-10-26); `POST /v1/ops/coupons/run-reminders` still works.
"{{1}} from {{2}} expires on {{3}} — show it at the counter before then"). Schedule
`POST /v1/ops/coupons/run-reminders` once a day (Railway cron or any scheduler with an ops token).

Dashboard extras (2026-10-26). Video thumbnails ride on the same ffmpeg switch: with ffmpeg on
the host every video upload (creatives, footage library, screen footage) gets a 480 px JPEG
poster frame next to its file; without it nothing is generated and the portal keeps showing the
video itself. After switching ffmpeg on, run `POST /v1/ops/footage/thumbnails/backfill` once
(the "Make missing thumbnails" button on ops Footage) to fill in older clips. Migration
`extras_weekly_report_waitlist` adds `users.weekly_report` (default on) and the `waitlist`
table. New env (default is fine): `WAITLIST_MAX_PER_IP=10`, early-access sign-ups per IP per
hour on the public landing page form.
## 4. CI, monitoring, analytics

- Every push / PR to `main` runs `.github/workflows/ci.yml`: backend pytest, web `tsc` + `next build`,
  and the Playwright journey suite against a real backend. No secrets needed. See `docs/TESTING.md`.
- Uptime: two free monitors — Railway `/healthz` and Vercel `/api/health` (which also pings the
  backend). Setup + alert rules in `docs/UPTIME.md`.
- Error tracking (Sentry) and product analytics (PostHog) are **off until their env vars are set**
  (`SENTRY_DSN` on Railway; `NEXT_PUBLIC_SENTRY_DSN`, `NEXT_PUBLIC_POSTHOG_KEY` on Vercel). Both are
  configured for zero PII. See `docs/UPTIME.md` and `docs/ANALYTICS.md`.
