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.
Quick Start (One-Line Binary)
Section titled “Quick Start (One-Line Binary)”Download and run the standalone binary on any Linux or macOS server:
curl -fsSL https://zirzir.dev/install.sh | shexport 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.
Docker Deployment
Section titled “Docker Deployment”Run the pre-built Docker image (published for linux/amd64 only; on ARM hosts, build it locally with docker build ./server):
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:latestBuild from Source
Section titled “Build from Source”Requirements: Rust 1.75+, Bun 1.0+
git clone https://github.com/recite-labs/zirzir.gitcd zirzir
# 1. Build the React web dashboardcd server/webbun 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 8080Configuration
Section titled “Configuration”Server settings are read from ZIRZIR_* environment variables or an optional config.yaml (env wins):
| Variable | Default | Description |
|---|---|---|
ZIRZIR_ADMIN_API_KEY | (empty) | Admin API key. /admin/* rejects all requests until set. |
ZIRZIR_HOST | 0.0.0.0 | Bind address |
ZIRZIR_PORT | 8080 | Listen 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_PATH | zirzir.db | SQLite 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_DIR | plugins | Bun 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_WEBHOOKS | false | Allow event delivery to private/loopback addresses (local development only) |
RUST_LOG | info,... | 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.
Public URL & webhooks
Section titled “Public URL & webhooks”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_urlbecomes your notification URL. When a payment settles, Zirzir POSTs a signedpayment.completed/payment.failed/payment.cancelledevent there, or to the merchant’swebhook_urlif nocallback_urlwas 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.
Credential encryption
Section titled “Credential encryption”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.
export ZIRZIR_ENCRYPTION_KEY="$(openssl rand -hex 32)"Other behavior
Section titled “Other behavior”- 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-Foris 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
pendingwith anerror_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.
Database & Backups
Section titled “Database & Backups”Zirzir uses SQLite in WAL mode with built-in versioned migrations, or PostgreSQL.
Zero-Downtime Hot Backups
Section titled “Zero-Downtime Hot Backups”Take consistent online database snapshots without locking readers or writers:
zirzir db backup --output /backups/zirzir-$(date +%Y%m%d).sqliteMigration Status Inspector
Section titled “Migration Status Inspector”zirzir db statusPostgreSQL
Section titled “PostgreSQL”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:
cargo build --release --features postgresZIRZIR_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 backupis 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.
Monitoring & Prometheus Metrics
Section titled “Monitoring & Prometheus Metrics”Zirzir exposes standard Prometheus telemetry at GET /metrics:
curl http://localhost:8080/metricsExposed metrics include:
zirzir_payments_total{status="all|success|failed"}zirzir_circuit_breaker_healthy{provider="..."}zirzir_uptime_seconds