Bridgedocs

Self-hosting Bridge

Bridge runs as one Go binary (API, worker and migrations) plus PostgreSQL, with an optional Next.js dashboard. Docker Compose wires them together.

Quick start

git clone https://github.com/kroszborg/bridge.git
cd bridge
docker compose up -d
docker compose ps        # migrate exits 0; api, worker, dashboard become healthy

That builds the images on your machine. To run a published release instead, put this in .env and use docker compose pull && docker compose up -d:

BRIDGE_IMAGE_PREFIX=ghcr.io/kroszborg/   # or kroszborg/ for Docker Hub
BRIDGE_VERSION=1.0.0

Release images are multi-architecture (amd64 and arm64) and carry signed build provenance: gh attestation verify oci://ghcr.io/kroszborg/bridge-api:1.0.0 --repo kroszborg/bridge.

Open http://localhost:3000, create your account, and create an API key.

What runs

ServiceImagePurpose
postgrespostgres:18.6-alpineData and the job queue (River). Not exposed on the host.
migratebridge-apiRuns bridge migrate once, then exits.
apibridge-apibridge serve: REST API on port 8080.
workerbridge-apibridge worker: background jobs (retries, maintenance).
dashboardbridge-dashboardNext.js console on port 3000. Proxies /api/* to api.

On a very small server you can drop the worker service and run the API with command: ["serve", "--worker"] instead.

Configuration

Copy .env.example to .env next to docker-compose.yml. Compose reads it automatically. The settings that matter for a public deployment:

VariableSet it to
POSTGRES_PASSWORDA long random value (URL-safe characters).
BRIDGE_PUBLIC_URLThe HTTPS URL of the API, e.g. https://api.sms.example.com. Android devices and your apps use it.
BRIDGE_DASHBOARD_URLThe HTTPS URL of the dashboard, e.g. https://sms.example.com.
BRIDGE_ALLOW_SIGNUPfalse once you have created your own account. Invite teammates from Team; invite links work with sign-up off.
BRIDGE_OPERATOR_EMAILSYour email. Operators see System health. When unset, the first account is the operator.
BRIDGE_SECRET_KEY32 random bytes as base64 or hex: openssl rand -base64 32. Needed to store SMS provider credentials, integration secrets, Verify apps' token signing and Turnstile secrets, and Telegram bot tokens for forwarding.
BRIDGE_SMTP_*Optional. An SMTP server for password reset links, email verification codes and forwarding incoming SMS by email. See Email.
BRIDGE_ACCOUNT_VERIFY_API_KEYOptional. An API key of one of your projects; users then verify a phone number with a code Bridge sends through that project's Verify. See Account verification.
BRIDGE_SITE_URLOptional. Your public website, e.g. https://sms.example.com. Sign-up then links to its /terms and /privacy.

When BRIDGE_DASHBOARD_URL uses https, session cookies are automatically marked Secure.

BRIDGE_SECRET_KEY encrypts provider credentials, integration signing secrets, Verify apps' token signing and Turnstile secrets, and forwarding rules' Telegram bot tokens (AES-256-GCM). Bridge starts without it, but refuses to save them until it is set, and the Verify widget cannot issue tokens. Back it up with your database backups: if it is lost or changed, stored credentials can no longer be read and must be entered again. The API and worker containers both need the same value.

BRIDGE_PUBLIC_URL must be reachable from the internet, over HTTPS, if you use SMS providers or integrations: providers post delivery reports to <BRIDGE_PUBLIC_URL>/v1/provider-callbacks/…, and the Supabase Send SMS hook URL is built from it. With a local URL, providers still send but messages stay sent because no delivery report arrives. It is also the iss claim of Verify widget tokens, so changing it invalidates tokens checked against the old value.

The Verify widget's script (<BRIDGE_DASHBOARD_URL>/widget.js) and hosted page (<BRIDGE_DASHBOARD_URL>/verify/…) are served by the dashboard, so it must be reachable by your users when you use them.

Request logs are kept for BRIDGE_REQUEST_LOG_RETENTION (default 336h, 14 days) and message bodies for BRIDGE_MESSAGE_RETENTION (default 720h). Forwarding delivery logs follow the message retention too.

The worker runs scheduled messages: it checks for due schedules once a minute. While no worker is running, nothing scheduled is sent; when it comes back, each overdue schedule sends once and continues, without replaying missed runs.

Webhooks are delivered only to public addresses. If your application runs on the same host or Docker network as Bridge, set BRIDGE_WEBHOOK_ALLOW_PRIVATE_ENDPOINTS=true; see Webhooks.

Android phones connect to BRIDGE_PUBLIC_URL (REST and the /v1/device/connect WebSocket). If you put Bridge behind a reverse proxy, make sure it forwards WebSocket upgrades and does not buffer /v1/events/stream (Server-Sent Events); Caddy handles both by default, and Bridge sends X-Accel-Buffering: no for nginx. Optional push settings for waking phones are described in docs/android/README.md.

Email

Bridge sends three kinds of email through your SMTP server: password reset links, verification codes for users' email addresses (see Account verification), and incoming SMS for forwarding rules with email destinations. Without these settings, the sign-in page hides "Forgot password?", users cannot verify or change their email address, email destinations are unavailable, and everything else works.

VariableDefaultNotes
BRIDGE_SMTP_HOSTThe mail server. Setting it turns email on.
BRIDGE_SMTP_PORT587
BRIDGE_SMTP_TLSstarttlsstarttls (usually port 587), tls (TLS from the first byte, usually 465), or none for a relay on a trusted network.
BRIDGE_SMTP_USERNAMELeave empty for a server that needs no login.
BRIDGE_SMTP_PASSWORD
BRIDGE_SMTP_FROMRequired with a host: the sender, such as Bridge <sms@example.com>.

Bridge refuses to start when BRIDGE_SMTP_HOST is set but the port, TLS mode or From address is invalid. With starttls, sending fails if the server does not offer STARTTLS; certificates are always verified, and credentials are only sent over TLS (or to localhost with none). Set the same values on the API (which checks email destinations) and the worker (which sends them); the Compose file passes them to both. Most mail providers need the From address to belong to the account you log in with.

Account verification

Users can prove their email address and phone number from Account in the dashboard. Both are optional and independent; GET /v1/auth/config reports which one the server offers (email_verification, phone_verification) and the dashboard hides the other.

Email. With SMTP configured, sign-up emails a 6-digit code, and a banner reminds the user until they enter it. They can ask for a new code once a minute, at most 5 an hour; a code expires after 15 minutes or 5 wrong attempts. Changing the email address works the same way: the user confirms their password, Bridge emails a code to the new address and a notice to the current one, and the address changes (already verified) once the code is entered. Password reset links sent to the old address stop working; sessions stay signed in.

Phone. Bridge verifies phone numbers with its own Verify, through one of your projects, so account verification doubles as an end-to-end check of your Verify setup:

  1. In a project of yours, pair a phone (or connect an SMS provider) and create an API key.
  2. Set BRIDGE_ACCOUNT_VERIFY_API_KEY to it on the API and restart.
VariableDefaultNotes
BRIDGE_ACCOUNT_VERIFY_API_KEYA bk_live_… or bk_test_… key. Bridge refuses to start if it is malformed.
BRIDGE_ACCOUNT_VERIFY_APPthe default appThe Verify app (ID or slug) whose message template, code length and limits the codes use.

With a live key, the code is a real SMS sent through that project's paired phones or SMS providers, and counts toward its usage like any other code. With a test key nothing is sent: the dashboard shows the code ("Test key: the code is …"), which is handy to try the flow, but proves nothing about the number, so use a live key in production. The codes appear in that project's Verify activity with the metadata {"purpose": "account_phone", "user_id": "usr_…"}.

The key is looked up each time a code is sent or checked: when it is revoked, expired or deleted, or BRIDGE_ACCOUNT_VERIFY_APP names no app of its project, phone verification answers 503 phone_verification_unavailable and the API logs the reason. A verified number belongs to one account (409 phone_in_use); users can change or remove theirs. Codes follow the Verify app's rules (expiry, attempts, one code per number every 30 seconds and 5 an hour, fraud protection), and each account can request at most 5 codes an hour.

Production checklist

  • TLS in front of both the API and the dashboard (Caddy, nginx, Traefik or a cloud load balancer).
  • POSTGRES_PASSWORD changed from the default.
  • BRIDGE_SECRET_KEY set and backed up, if you use SMS providers, integrations, the Verify widget or Telegram forwarding.
  • BRIDGE_SMTP_* set, if you want password reset, email verification or forwarding by email.
  • BRIDGE_ACCOUNT_VERIFY_API_KEY set to a live key, if users should verify phone numbers.
  • BRIDGE_ALLOW_SIGNUP=false after creating your account.
  • Your reverse proxy's address is covered by BRIDGE_TRUSTED_PROXIES (private networks are trusted by default), so rate limits see real client IPs.
  • PostgreSQL backups. The pgdata volume holds everything, including queued jobs.
  • Logs collected from the containers (JSON in production).

Example: Caddy

For a complete single-server setup with Caddy included, see Deploying to AWS Lightsail.

sms.example.com {
  reverse_proxy localhost:3000
}

api.sms.example.com {
  reverse_proxy localhost:8080
}

Upgrading

git pull
docker compose build
docker compose up -d     # migrate runs first; api and worker wait for it

Migrations are forward-only and safe to run concurrently (they take a Postgres advisory lock). Read the changelog for breaking changes before upgrading.

Health checks

  • GET /healthz returns 200 when the process is up.
  • GET /readyz returns 200 when the database is reachable.
  • Inside the distroless image, bridge healthcheck probes /healthz (used by Compose).

Status page and System health

Every Bridge installation has a public status page at <BRIDGE_DASHBOARD_URL>/status, backed by GET /v1/status (no sign-in, cached for 15 seconds). It shows the current state and 90 days of uptime for the API, the database, message processing, webhook delivery and, for information only, phones. A phone going offline is its owner's to fix, so it never marks Bridge as down.

How it is measured:

  • API and worker processes check in every 15 seconds. When no worker has checked in for a minute, message processing and webhook delivery are an outage.
  • Jobs waiting more than 60 seconds to start is degraded; more than 5 minutes is an outage.
  • The worker records every component once a minute and keeps 90 days of samples. A day is an outage below 95% healthy samples and degraded below 99%.

Operators (see BRIDGE_OPERATOR_EMAILS) also get System health in the dashboard: running processes, job queues by state, messages waiting for a phone, database size and connections, and retention settings. The page refreshes every 10 seconds.

For alerting, point an external monitor at GET /readyz and GET /v1/status; the latter's status field is operational, degraded or outage.

Public website

apps/web is the project website: a static export with no server. Build it with pnpm --filter @bridge/web build and serve apps/web/out from any static host. Set these at build time:

VariablePurpose
NEXT_PUBLIC_DASHBOARD_URLWhere "Open the dashboard" and the status link point. Default http://localhost:3000.
NEXT_PUBLIC_REPO_URLThe source repository. Default https://github.com/kroszborg/bridge.

Running without Docker

cd apps/api
go build -o bridge ./cmd/bridge
export BRIDGE_DATABASE_URL="postgres://…"
./bridge migrate
./bridge serve --worker

The dashboard is a standard Next.js app: pnpm --filter @bridge/dashboard build && pnpm --filter @bridge/dashboard start, with BRIDGE_API_URL pointing at the API.

Edit on GitHub

On this page