Skip to content

Self-Hosting

The Zirzir server is a self-contained, high-performance binary written in Rust (Axum 0.7) with an embedded SQLite ledger and a React dashboard.


Download and run the standalone binary on any Linux or macOS server:

Terminal window
curl -fsSL https://zirzir.dev/install.sh | sh
export ZIRZIR_ADMIN_API_KEY="$(openssl rand -hex 32)"
zirzir --port 8080 server

--port, --config, and --plugins-dir are global flags and go before the subcommand. Running zirzir with no subcommand also starts the server.

The installer verifies the release archive against its published SHA256 checksum and exits non-zero if the download or verification fails.

Open http://localhost:8080 to access the developer dashboard.


Run the pre-built Docker image (published for linux/amd64 only; on ARM hosts, build it locally with docker build ./server):

Terminal window
docker run -d \
--name zirzir \
-p 8080:8080 \
-v zirzir-data:/app/data \
-v $(pwd)/plugins:/app/plugins:ro \
-e ZIRZIR_ADMIN_API_KEY="$(openssl rand -hex 32)" \
-e ZIRZIR_PUBLIC_URL="https://pay.example.com" \
-e ZIRZIR_ENCRYPTION_KEY="<long random secret, kept safe>" \
-e ZIRZIR_WEBHOOK_SECRET="<long random secret>" \
ghcr.io/recite-labs/zirzir/server:latest

Requirements: Rust 1.75+, Bun 1.0+

Terminal window
git clone https://github.com/recite-labs/zirzir.git
cd zirzir
# 1. Build the React web dashboard
cd server/web
bun install && bun run build
# 2. Build the Rust release binary (rust-embed needs web/dist from step 1)
cd ../
cargo build --release --locked
# 3. Run the standalone server
./target/release/zirzir-server --plugins-dir ../plugins --port 8080

Server settings are read from ZIRZIR_* environment variables or an optional config.yaml (env wins):

VariableDefaultDescription
ZIRZIR_ADMIN_API_KEY(empty)Admin API key. /admin/* rejects all requests until set.
ZIRZIR_HOST0.0.0.0Bind address
ZIRZIR_PORT8080Listen port (or --port)
ZIRZIR_PUBLIC_URL(empty)server.public_url: the server’s externally reachable base URL, e.g. https://pay.example.com. See Public URL & webhooks.
ZIRZIR_ALLOW_ORIGINS*CORS origins, comma-separated
ZIRZIR_DATABASE_PATHzirzir.dbSQLite file (/app/data/zirzir.db in Docker)
ZIRZIR_DATABASE_URL—postgres://user:pass@host/db, for a build with PostgreSQL (see below)
ZIRZIR_ENCRYPTION_KEY(empty)security.encryption_key: encrypts provider credentials at rest. See Credential encryption.
ZIRZIR_PLUGINS_DIRpluginsBun plugin directory (or --plugins-dir)
ZIRZIR_WEBHOOK_SECRET(empty)HMAC secret for signing merchant webhook events and replays. No events are sent while unset.
ZIRZIR_ALLOW_PRIVATE_WEBHOOKSfalseAllow event delivery to private/loopback addresses (local development only)
RUST_LOGinfo,...Log verbosity (tracing filter)

Provider credentials are not part of the server config. Set them per project with zirzir provider set <code> ... (after zirzir login) or from the dashboard.

Set ZIRZIR_PUBLIC_URL in production. When it is set:

  • Providers are told to call back {public_url}/api/v1/webhooks/{provider} automatically.
  • The per-payment callback_url becomes your notification URL. When a payment settles, Zirzir POSTs a signed payment.completed / payment.failed / payment.cancelled event there, or to the merchant’s webhook_url if no callback_url was given.

When it is unset, callback_url is passed straight to the provider (the old behavior), events go only to the merchant webhook_url, and the server logs a warning at startup.

Events are signed with ZIRZIR_WEBHOOK_SECRET (X-Zirzir-Signature: t=<unix>,v1=<hex HMAC-SHA256 of "{t}.{body}">). Failed deliveries are retried after 5m, 15m, 1h and 24h. Targets on private or loopback addresses are refused unless ZIRZIR_ALLOW_PRIVATE_WEBHOOKS=true. See Webhooks for the event format and the provider settlement matrix.

Inbound provider callbacks only change a payment when they are signed (LakiPay with webhook_public_key, Telegram plugin with webhook_secret) or re-verified with the provider’s status API (Chapa, Arifpay, LakiPay, SantimPay, M-Pesa and Airtel plugins). Telebirr and CBE Birr cannot be settled automatically because they have no status API. Reconcile those payments in the dashboard.

When ZIRZIR_ENCRYPTION_KEY is set, provider credentials are encrypted at rest with AES-256-GCM (stored as enc:v1:...). Existing plaintext rows are re-encrypted on startup. Without a key, credentials are stored unencrypted and the server logs a warning.

Back the key up separately from the database. If you lose it, the stored credentials cannot be decrypted and you must re-enter every provider’s credentials.

Terminal window
export ZIRZIR_ENCRYPTION_KEY="$(openssl rand -hex 32)"
  • Payment amounts must be positive with at most 2 decimal places, and are rounded to 2 dp.
  • Logging out revokes the dashboard session. Session tokens are stored hashed; upgrading from an earlier version signs every user out once.
  • Login and signup share a limit of 10 requests per minute per client IP (IPv6 clients are grouped per /64). X-Forwarded-For is only trusted when the connection comes from a loopback or private address, so run a single reverse proxy that sets it. Behind several proxy hops, or a Docker bridge without a proxy, all clients share one bucket.
  • With a key set, the server refuses to start if it cannot encrypt existing plaintext credentials. Use a long random key; keys under 32 characters log a warning.
  • A charge whose outcome is unknown (timeout, dropped connection, plugin crash) is returned as pending with an error_message, not as an error, so clients don’t retry into a double charge. Webhooks or the status poller settle it later.
  • Subscriptions keep one charge in flight at a time. If a charge stays unsettled for 72 hours, the subscription is marked past_due.
  • Merchant event webhooks don’t follow redirects.

Zirzir uses SQLite in WAL mode with built-in versioned migrations, or PostgreSQL.

Take consistent online database snapshots without locking readers or writers:

Terminal window
zirzir db backup --output /backups/zirzir-$(date +%Y%m%d).sqlite
Terminal window
zirzir db status

SQLite is the default and needs nothing else. For a larger deployment, or to use managed database backups and failover, zirzir can keep its data in PostgreSQL instead (CI tests it on PostgreSQL 17). Build it with the postgres feature and point it at a database:

Terminal window
cargo build --release --features postgres
ZIRZIR_DATABASE_URL=postgres://zirzir:secret@db.internal/zirzir ./target/release/zirzir-server
  • The schema is created and migrated at start-up, as with SQLite. Create an empty database and a user that owns it.
  • Every feature behaves the same: the full test suite runs against both databases in CI.
  • Back it up with your PostgreSQL tooling (pg_dump, point-in-time recovery); zirzir db backup is for SQLite files.
  • The ledger follows the same rule: without TigerBeetle addresses, it keeps its books in this database. For production volume, run TigerBeetle alongside either database.
  • Run one zirzir server per database for now: a few safeguards (wallet limits, SACCO movements per member) serialise work inside one process. Servers starting together take turns migrating.
  • zirzir’s queries compute days in UTC themselves, whatever the connection’s time zone, and migrations lock per transaction, so it also works through a connection pooler such as PgBouncer in transaction mode.

Zirzir exposes standard Prometheus telemetry at GET /metrics:

Terminal window
curl http://localhost:8080/metrics

Exposed metrics include:

  • zirzir_payments_total{status="all|success|failed"}
  • zirzir_circuit_breaker_healthy{provider="..."}
  • zirzir_uptime_seconds