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. PointBISIBILITY_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:
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.
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.
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:
http://localhost:8233,
and the worker built from Dockerfile.worker.
Change the published ports
The stack publishes the app on3000, PostgreSQL on 5432, and Redis/Valkey on
6379. When a port is already taken, override it in .env:
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
- Start from the Railway deploy link.
- Add a PostgreSQL service.
- Add a Redis or Valkey service and configure
REDIS_URL. - Set
DATABASE_URLandDIRECT_URLto the PostgreSQL private connection URL. - Set the required environment variables. The root
railway.jsonselects the web Dockerfile, healthcheck, migration command, and restart policy. - 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. - Point the app and worker at either Temporal Cloud or a self-hosted Temporal deployment reachable inside the Railway private network.
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
-
Create separate web and worker apps:
- Provision PostgreSQL and a Redis-compatible service, or attach managed endpoints reachable from the Fly private network.
-
Set the required secrets on both apps. The web app also needs its public
SITE_URLandBETTER_AUTH_URL. -
Deploy the checked-in manifests:
-
The web manifest runs
prisma migrate deployas a Fly release command and checks/api/v1/healthbefore routing traffic. - Connect both apps to Temporal Cloud, or operate Temporal as separate Fly apps with persistent storage.
Vercel
- Start from the Vercel clone link.
- Attach a PostgreSQL database.
- Configure a Redis or Valkey endpoint.
- Set the required environment variables.
- Run migrations from a separate environment that can reach the database.
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: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:
Optional environment variables
Instance settings
Theinstance_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:
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 useSlackConnection, 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:
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 andPOST /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:
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 therank_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.