Skip to main content
bisibility is a Next.js app with PostgreSQL for durable data. Redis or Valkey backs API rate limiting, idempotency, and realtime notification fan-out. Rank checks use your own connected SERP provider credentials. Read the deployment overview: The public repository opens with the first public release. The options below describe the supported deployment paths, but clone and one-click deploy targets are not available before that release.

Choose your deployment

Start with the path that matches what you need now. You can add scheduled checks later without changing the rank history already stored in PostgreSQL.

Before you deploy

The platform links below create only part of the final setup. Review every generated resource, set secrets, and confirm migrations before serving production traffic.

Production topology

Railway and Fly.io can each run the complete bisibility stack as multiple services on one platform: The web app and worker share one Prisma schema, so deploy both from the same release tag whenever migrations are applied. Follow the same-version upgrade rule to avoid version skew during split deployments.

Client IP behind a proxy

The app never guesses the client address from a header a client can set. Point BISIBILITY_CLIENT_IP_HEADER at a header your own proxy controls. With nginx or Caddy in front of the app, have the proxy overwrite a dedicated header:
then set BISIBILITY_CLIENT_IP_HEADER=x-real-ip. Any header the proxy overwrites works the same way, including a CDN header such as cf-connecting-ip. Behind a CDN that appends to x-forwarded-for, set BISIBILITY_CLIENT_IP_HEADER=x-forwarded-for and BISIBILITY_CLIENT_IP_XFF_DEPTH to the number of entries your own infrastructure appends at the end of the chain (1 for a single edge). The address is counted from the right, so a client cannot move itself into that position by sending its own x-forwarded-for prefix. A depth larger than your real chain makes the app read a client-supplied entry, so verify the value before relying on it.
Do not point BISIBILITY_CLIENT_IP_HEADER at x-forwarded-for unless a proxy you control appends to it. The Next.js server fills x-forwarded-for from the connection only when the client did not send one, and leaves a client-supplied value untouched, so with no proxy in front every caller can choose its own rate-limit bucket by sending the header. A proxy that appends (nginx with $proxy_add_x_forwarded_for) or overwrites (X-Real-IP) removes that.
Without one of these, per-IP limiting is not possible inside the app: anonymous API callers share a single bucket, sign-in rate limiting falls back to one shared bucket per path, and audit entries record no source IP. Enforce the anonymous limit at the edge instead (a rate rule on your CDN or WAF, or limit_req in nginx); the proxy knows the peer address directly and needs no configuration to trust it. “Complete stack” does not have to mean running Temporal yourself. The web app and worker can use Temporal Cloud while PostgreSQL, Redis, and the application processes remain on Railway or Fly.io. Running Temporal Server on the same platform is also possible, but it adds Temporal persistence, upgrades, backups, and monitoring to your operational responsibilities.

Docker

Availability: The public repository opens with the first public release. Until then, read the deployment overview above; the clone command is not yet available.
Open http://localhost:3000 and sign in with the demo login enabled by the two DEMO_* variables above. For real deployments, remove both variables and configure an email provider instead. The default Docker stack starts PostgreSQL, Redis/Valkey, a one-shot migrate service, and the web app. The migrate service runs prisma migrate deploy before the app starts. When DEMO_FIXED_OTP=1, it also seeds demo data for the local dashboard. This default mode supports manual rank checks. Scheduled checks require Temporal and the worker:
That profile adds a Temporal server, Temporal Web UI on http://localhost:8233, and the worker built from Dockerfile.worker.

Change the published ports

The stack publishes the app on 3000, PostgreSQL on 5432, and Redis/Valkey on 6379. When a port is already taken, override it in .env:
A new app port also has to be set in SITE_URL and BETTER_AUTH_URL (http://localhost:3100 for the example above). Both are used for auth callbacks and absolute links, so a mismatch breaks sign-in. If you use Google sign-in or Search Console, the redirect URIs registered with Google must carry the same port too. To run more than one instance on one machine, give each its own ports and its own Compose project name (docker compose -p bisibility-staging up --build). The project name namespaces the volumes, so every instance keeps a separate database.

Stop or reset the stack

down preserves the PostgreSQL volume, so the next up continues with the same data and applies only new migrations. down -v cannot be undone; use it when you want a clean install.

Examples

Runnable API, SDK, CLI, and MCP examples live in examples/README.md. Deploy webhook examples live in examples/deploy-webhooks.

Railway

  1. Start from the Railway deploy link.
  2. Add a PostgreSQL service.
  3. Add a Redis or Valkey service and configure REDIS_URL.
  4. Set DATABASE_URL and DIRECT_URL to the PostgreSQL private connection URL.
  5. Set the required environment variables. The root railway.json selects the web Dockerfile, healthcheck, migration command, and restart policy.
  6. For scheduled checks, create a second service from the same repository, set its config file path to /deploy/railway-worker.json, and share the database, Redis, and Temporal variables with it.
  7. Point the app and worker at either Temporal Cloud or a self-hosted Temporal deployment reachable inside the Railway private network.
Use the Railway-provided app URL for SITE_URL and BETTER_AUTH_URL, or set both to your custom domain after DNS is configured. SITE_URL is the primary origin for metadata, sitemap, robots.txt, social images, OAuth redirects, and auth callbacks.

Fly.io

  1. Create separate web and worker apps:
  2. Provision PostgreSQL and a Redis-compatible service, or attach managed endpoints reachable from the Fly private network.
  3. Set the required secrets on both apps. The web app also needs its public SITE_URL and BETTER_AUTH_URL.
  4. Deploy the checked-in manifests:
  5. The web manifest runs prisma migrate deploy as a Fly release command and checks /api/v1/health before routing traffic.
  6. Connect both apps to Temporal Cloud, or operate Temporal as separate Fly apps with persistent storage.
Keeping the worker separate from the web process allows independent scaling and prevents a web deployment from interrupting scheduled work.

Vercel

  1. Start from the Vercel clone link.
  2. Attach a PostgreSQL database.
  3. Configure a Redis or Valkey endpoint.
  4. Set the required environment variables.
  5. Run migrations from a separate environment that can reach the database.
Vercel is suitable for the web app, REST API, and manual checks. It cannot host the long-lived npm run temporal:worker process. For scheduled checks, host the worker image from Dockerfile.worker on Railway, Fly.io, or a VPS. The web app and worker can connect to Temporal Cloud or to your own network-reachable Temporal Server.

Temporal Server or Temporal Cloud

Set the same address, namespace, and task queue on the web app and worker. For a self-hosted plaintext Temporal Server on a private network:
For Temporal Cloud:
TEMPORAL_API_KEY automatically enables TLS. Set TEMPORAL_TLS=true when a TLS-enabled self-hosted endpoint does not use an API key. Do not expose the API key through any NEXT_PUBLIC_ variable.

Required environment variables

Both legal variables are optional and empty by default. Your instance runs under your own terms and privacy policy, so the sign-in screen shows no consent line until you point these at your documents. Generate BETTER_AUTH_SECRET and BISIBILITY_SECRETS_KEY with:
Keep both values for the lifetime of the instance and back them up with your other secrets. Replacing BETTER_AUTH_SECRET signs every user out. Replacing BISIBILITY_SECRETS_KEY leaves the provider credentials already stored in the database undecryptable, and every connected provider has to be reconnected by hand.

Optional environment variables

Instance settings

The instance_settings database table holds small instance-wide limits that the app and worker share. Missing or invalid values use these code defaults: Set a value from an operator environment with DATABASE_URL configured:
The command validates the key and value and prints the previous and current values for the operator record. App reads refresh within about 60 seconds.
Upgrade note: The former RANK_CHECK_MONTHLY_COST_CAP_CENTS variable has been removed. The monthly provider budget is now a per-workspace setting, and the old variable is ignored after upgrade. Re-set your budget in Settings > Provider usage after upgrading. See Budget cap.

Operator observability (optional)

Operator observability is an instance-level channel for proving that the Temporal worker and scheduled data work are healthy. It is deliberately separate from tenant-facing Slack alert channels and does not use SlackConnection, project OAuth tokens, or alert rules. It is off and silent when OPS_SLACK_WEBHOOK_URL is unset. Slack payloads carry identifiers and counts only by default. Keyword text, project names, email addresses, and tenant domains are excluded. This is the payload contract, rather than a best-effort redaction rule, because Slack is an external processor and GDPR data minimization applies. Set OPS_SLACK_INCLUDE_NAMES=1 only when the operator explicitly chooses to include keyword text, project names, and Search Console property names for a self-hosted instance. Create a Slack app for your workspace, enable Incoming Webhooks, and add a webhook for the operator channel. Configure the same OPS_* values on both the web and worker services because web-side health reporting uses the feature gate. Both services must also share the same Redis database for worker liveness. Give the worker SITE_URL so digest links and Slack footers identify the instance. Configure TEMPORAL_UI_URL only on the worker when you want the digest to link to Temporal UI. On managed platforms, place the shared values in an environment group attached to both services. Failure events cover rank checks, deferred checks, traffic-source syncs, and schedule bootstrap. URL Inspection budget exhaustion identifies the property with a stable hashed identifier and lists affected project IDs when OPS_NOTIFY_MODE=failures; the raw property name appears only when OPS_SLACK_INCLUDE_NAMES=1. Startup events confirm how many worker schedules were ensured. Events are first persisted in an outbox, Slack delivery failures are retried by the next heartbeat, and payloads redact credentials, tokens, and URL query strings. Repeated events within the throttle window are counted and summarized instead of posted individually. The daily digest summarizes the previous 24 hours: rank-check outcomes and scheduled-to-start lag, top failures, Temporal missed-catchup and skipped-overlap signals, GSC/GA4/Plausible freshness and row counts per project, undelivered and suppressed events, worker uptime, release, and environment. Set TEMPORAL_UI_URL to include a Temporal UI link alongside the checks page link. Observability records are retained for 30 days. The Google OAuth client belongs to a Google Cloud project, which owns its own per-project Google API quota. Separately, multiple bisibility projects that point to the same Search Console property share that property’s 2,000-per-day URL Inspection pool, including usage by other tools connected to that property. After configuring the webhook, send a direct formatting and delivery test from the worker environment:
GET /api/v1/health additively reports services.worker as ok, stale, or unknown. A worker is stale when its startup/heartbeat marker is older than 26 hours; stale worker health returns HTTP 503. The Slack heartbeat is a dead-man’s switch read by absence: Slack cannot alert you when the expected message never arrives. For full process or host outage detection, also ping an external dead-man monitor such as healthchecks.io from the heartbeat path.

Instance admin

An empty self-hosted installation sends marketing, sign-in, onboarding, and application pages to /setup. Enter a name and email address, then verify the one-time code to create the administrator account. Account creation, administrator assignment, and the completion audit are committed together, so only one submission can complete setup. The setup page is intentionally open while the installation has no accounts. If email delivery is not configured, the first sign-in code is printed in the server logs so the operator can finish setup. After the account is created, codes never fall back to logs in production, /setup never offers account creation again, and non-admin accounts receive a not-found response for /app/admin.
Upgrade note: Existing installations with user accounts do not enter the setup flow, even if no administrator is assigned. Use the seed command below to choose an administrator after upgrading.
The instance admin panel at /app/admin provides cross-instance operational visibility without extending project permissions. It includes worker liveness and deployment identity, rank-check totals and schedule lag for the last 24 hours and 7 days, recent rank-check failures, analytics-source freshness and row counts, Temporal schedule signals, the operator-event outbox, and instance-wide counts and current-month rank-check cost. It also provides rate-limited actions to send a test Slack notification and retry undelivered operator events. Existing deployments can grant the role by email after running migrations:
Run it inside the deployed app container, where the script and the database connection are already in place - for example docker compose exec app node --experimental-transform-types scripts/admin/seed-instance-admin.ts --email operator@example.com on the Docker stack, or the equivalent shell on your platform (fly ssh console, railway ssh). It also works from a repository checkout on any machine, as long as DATABASE_URL points at the instance database - for hosted databases that is the public connection string, not the platform-internal hostname. The seed is idempotent when the target account is already an instance admin. It refuses to grant another account while an admin exists; pass --force to grant the named account as an additional admin without revoking existing admins. The email may instead be supplied through INSTANCE_ADMIN_EMAIL. The panel’s privacy contract is strict: cross-tenant lists contain identifiers, counts, statuses, durations, and categorized error summaries only. They never contain keyword text, project names, email addresses, tenant domains, or account data. Instance administration does not grant access to tenant project data; any link into a project is still checked by the normal project authorization path. The panel remains useful when operator Slack is disabled. Worker liveness is maintained independently through the shared Redis marker, instance statistics remain available, and Slack-specific status and actions show that Slack is not configured instead of failing the page. For local development without SERP credentials, set BISIBILITY_FAKE_PROVIDER=1 to exercise rank-check flows without calling a real SERP provider.

Scheduled rank checks

Manual rank checks are available through the app and POST /api/v1/keywords/{id}/checks without Temporal. If the app cannot reach Temporal, interactive manual checks fall back to inline execution. Scheduled checks are executed by the Temporal worker. With Docker, start the scheduled profile:
For non-Docker local development, run a Temporal server and the worker alongside the web app:
The worker reconciler is enabled by default. It converts keyword schedule intent stored by the app into Temporal Schedules, then executes due rank checks through the same runner used by manual checks. Set RANK_CHECK_RECONCILER_ENABLED=0 only for maintenance windows where you intentionally want schedule convergence paused. The worker also owns the Temporal maintenance schedules. Set SCHEDULED_MAINTENANCE_ENABLED explicitly on the worker so maintenance behavior does not depend on the rank-check reconciler default. This main gate controls six schedules: audit purge, session and verification purge, stale rank-check cleanup, stale import-job cleanup, migration-hold release, and the weekly digest. Sitemap and presence sync remain separate opt-in schedules controlled by SITEMAP_SYNC_ENABLED and PRESENCE_SYNC_ENABLED. Their complete worker configuration is SCHEDULED_MAINTENANCE_ENABLED, AUDIT_PURGE_CRON, SESSION_PURGE_CRON, WEEKLY_DIGEST_CRON, SITEMAP_SYNC_ENABLED, SITEMAP_SYNC_CRON, PRESENCE_SYNC_ENABLED, and PRESENCE_SYNC_CRON. Alert digest delivery is independent of this maintenance gate. It is enabled by default and controlled by ALERT_DIGEST_FLUSH_ENABLED and ALERT_DIGEST_FLUSH_INTERVAL. Temporal worker schedules are the only scheduled maintenance path. You do not need an external HTTP scheduler or a shared bearer secret. Session cleanup also removes expired verification rows; the retired HTTP-only verifications=false option had no repository caller and is not reproduced by the worker schedule. Rank checks are bounded by the workspace’s monthly provider budget, set in Settings > Provider usage. See Budget cap.

Database growth

Maintenance purges audit rows, expired sessions, and stale job records, but it does not trim rank history. Every completed check stores its provider response, so the rank_checks table grows with keyword count multiplied by check frequency and never shrinks on its own. There is no retention setting for stored provider responses yet. Plan disk accordingly, and delete projects or keywords you no longer track if you need the space back. Configurable retention for stored payloads is on the roadmap.