- Docs
- Run in Production
- Deployment Guide
From laptop to production.
This page shows the smallest working way to run bunqueue in production, then ready-to-paste configs for Kubernetes, Docker, systemd, and PM2, plus health checks and backups.
bunqueue uses one process and memory/SQLite by default. Server mode may instead use PostgreSQL 15–18 as the authoritative store for multiple active broker processes. PostgreSQL 18.6 is the recommended release and is pinned by the repository Compose topology. Start by deciding whether the queue is embedded, a single SQLite server, or a PostgreSQL-backed broker fleet.
The smallest deploy: embedded mode
Section titled “The smallest deploy: embedded mode”Embedded mode means the queue runs inside your application process, no separate queue server at all. Point it at a file path so jobs survive restarts:
import { Queue, Worker } from 'bunqueue/client';
const queue = new Queue('emails', { embedded: true, dataPath: './data/bunq.db', // jobs persist here});
await queue.add('send', { to: 'user@example.com' });
new Worker( 'emails', async (job) => { await sendEmail(job.data); return { sent: true }; }, { embedded: true, dataPath: './data/bunq.db', concurrency: 5 });Deploy your app the way you already deploy it. The queue ships with it. Done.
Separate apps and workers? Run the server
Section titled “Separate apps and workers? Run the server”The moment your API and your workers are separate processes (or separate containers), run the bunqueue server. One server owns the SQLite file; every other process talks to it over TCP:
bunqueue start --data-path ./data/bunq.db// api.ts, pushes jobs (no embedded flag = TCP client to localhost:6789)import { Queue } from 'bunqueue/client';const queue = new Queue('tasks');await queue.add('process', { data: '...' });// worker.ts, a separate process, restart and scale it independentlyimport { Worker } from 'bunqueue/client';new Worker( 'tasks', async (job) => { return { done: true }; }, { concurrency: 10 });You can also push jobs without any SDK, via CLI or HTTP:
bunqueue push emails '{"to": "user@example.com"}'
curl -X POST http://localhost:6790/queues/emails/jobs \ -H "Content-Type: application/json" \ -d '{"data": {"to": "user@example.com"}}'Multiple active brokers: PostgreSQL 15–18
Section titled “Multiple active brokers: PostgreSQL 15–18”Use the repository’s pinned Compose topology when one broker is not enough:
POSTGRES_PASSWORD='replace-me' \ BUNQUEUE_POSTGRES_URL='postgres://bunqueue:replace-me@postgres:5432/bunqueue' \ docker compose -f docker-compose.postgres.yml up --build -dThis starts postgres:18.6-alpine, broker-a on TCP/HTTP 6789/6790, and
broker-b on 7789/7790. Both brokers share one URL and namespace and have
different IDs. CI runs the complete PostgreSQL integration suite against majors
15, 16, 17, and the pinned 18.6 release; use 18.6 for new deployments unless a
provider constraint requires another tested major. Configure the equivalent
deployment with:
BUNQUEUE_STORAGE_DRIVER=postgresBUNQUEUE_POSTGRES_URL='postgres://bunqueue:percent-encoded-password@postgres:5432/bunqueue'BUNQUEUE_POSTGRES_NAMESPACE=productionBUNQUEUE_BROKER_ID=broker-a # unique for every active processCompose passes BUNQUEUE_POSTGRES_URL directly instead of placing the raw
POSTGRES_PASSWORD inside a URI. Set both values when overriding the default;
percent-encode reserved characters only in the URL password component.
PostgreSQL mode is standalone-server only. Do not set BUNQUEUE_DATA_PATH, and
do not enable bunqueue’s SQLite S3 snapshot feature. Use PostgreSQL-native
backups/PITR and put a TCP load balancer or service in front of the brokers. See
Storage backends for the lease and consistency model.
Kubernetes: four brokers and one PostgreSQL service
Section titled “Kubernetes: four brokers and one PostgreSQL service”Use a managed PostgreSQL service or a PostgreSQL operator with tested backup, PITR, failover, and monitoring for production. The manifest below deliberately contains only the bunqueue fleet: inject the connection URL for your database through a Secret and replace the example image with an immutable tag or digest from your registry.
apiVersion: v1kind: Secretmetadata: name: bunqueuetype: OpaquestringData: # Prefer an external secret manager or Sealed Secret in a real cluster. postgres-url: postgres://bunqueue:percent-encoded-password@postgres:5432/bunqueue auth-tokens: replace-with-a-long-random-token---apiVersion: v1kind: Servicemetadata: name: bunqueuespec: selector: app.kubernetes.io/name: bunqueue ports: - name: tcp port: 6789 targetPort: tcp - name: http port: 6790 targetPort: http---apiVersion: apps/v1kind: Deploymentmetadata: name: bunqueuespec: replicas: 4 # bunqueue schema upgrades do not support mixed binary versions. This avoids # overlap during a version change, at the cost of a coordinated outage. strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: bunqueue template: metadata: labels: app.kubernetes.io/name: bunqueue spec: automountServiceAccountToken: false securityContext: seccompProfile: type: RuntimeDefault terminationGracePeriodSeconds: 45 initContainers: - name: wait-for-postgres image: postgres:18.6-alpine command: ['/bin/sh', '-ec'] args: - until pg_isready -d "$BUNQUEUE_POSTGRES_URL"; do sleep 1; done env: - name: BUNQUEUE_POSTGRES_URL valueFrom: secretKeyRef: name: bunqueue key: postgres-url securityContext: allowPrivilegeEscalation: false capabilities: drop: ['ALL'] runAsGroup: 70 runAsNonRoot: true runAsUser: 70 containers: - name: bunqueue image: your-registry.example/bunqueue:immutable-tag imagePullPolicy: IfNotPresent ports: - { name: tcp, containerPort: 6789 } - { name: http, containerPort: 6790 } env: # The repository image defaults DATA_PATH to SQLite. Clear it # explicitly so PostgreSQL is the only configured backend. - { name: DATA_PATH, value: '' } - { name: BUNQUEUE_STORAGE_DRIVER, value: postgres } - name: BUNQUEUE_POSTGRES_URL valueFrom: secretKeyRef: name: bunqueue key: postgres-url - { name: BUNQUEUE_POSTGRES_NAMESPACE, value: production } - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name - { name: BUNQUEUE_BROKER_ID, value: '$(POD_NAME)' } - { name: BUNQUEUE_POSTGRES_POOL_SIZE, value: '4' } - { name: BUNQUEUE_POSTGRES_LEASE_DURATION_MS, value: '30000' } - { name: BUNQUEUE_POSTGRES_POLL_INTERVAL_MS, value: '250' } - { name: SHUTDOWN_TIMEOUT_MS, value: '30000' } - name: AUTH_TOKENS valueFrom: secretKeyRef: name: bunqueue key: auth-tokens startupProbe: httpGet: { path: /healthz, port: http } failureThreshold: 30 periodSeconds: 2 livenessProbe: httpGet: { path: /healthz, port: http } failureThreshold: 3 periodSeconds: 10 readinessProbe: httpGet: { path: /ready, port: http } failureThreshold: 3 periodSeconds: 5 resources: requests: { cpu: 100m, memory: 128Mi } limits: { cpu: '1', memory: 512Mi } securityContext: allowPrivilegeEscalation: false capabilities: drop: ['ALL'] readOnlyRootFilesystem: true runAsGroup: 1001 runAsNonRoot: true runAsUser: 1001---apiVersion: policy/v1kind: PodDisruptionBudgetmetadata: name: bunqueuespec: minAvailable: 1 selector: matchLabels: app.kubernetes.io/name: bunqueueThe initContainer is intentional. A bunqueue process fails fast when the
database is unreachable during startup; an HTTP readiness probe cannot delay a
dependency that must exist before the process starts. Once running, /ready
removes a broker from Service endpoints when PostgreSQL or a maintenance loop is
degraded, while /healthz remains a process-liveness signal. The PostgreSQL
schema initialization lock makes simultaneous cold starts safe.
Every Pod must have a different BUNQUEUE_BROKER_ID. The Downward API mapping
above supplies the immutable Pod name, including after replacement. All Pods
must use the same PostgreSQL URL and BUNQUEUE_POSTGRES_NAMESPACE. Budget at
least replicas × BUNQUEUE_POSTGRES_POOL_SIZE database connections, plus room
for migrations, administration, monitoring, replicas, and failover. The
resource values are starting points, not capacity claims; benchmark the actual
payload, retention, concurrency, and database latency.
The ClusterIP Service balances TCP connections, not individual commands. A
long-lived SDK connection remains on its selected broker; connection pools and
independent clients distribute naturally across ready endpoints. Keep both
ports private or add authenticated TLS termination. Set
terminationGracePeriodSeconds higher than SHUTDOWN_TIMEOUT_MS, and add
topology spread or anti-affinity appropriate to your node count.
Docker
Section titled “Docker”For the prebuilt server, use Docker Hub (available from 2.9.5):
docker run -d --name bunqueue \ -p 6789:6789 -p 6790:6790 \ -v bunqueue-data:/app/data \ egeominotti/bunqueue:2.9.5The same release is available as ghcr.io/egeominotti/bunqueue:2.9.5.
Both registries provide Linux amd64 and arm64 images. The volume preserves SQLite
data across container replacement. Pin a version or digest for deployments.
Choose 2.9.5-alpine, 2.9.5-debian, 2.9.5-slim, or 2.9.5-distroless.
The moving tags are alpine, debian, slim, and distroless. Unsuffixed version
tags and latest continue to select Alpine. All variants support amd64 and arm64,
run as UID/GID 1001:1001, and share the same ports and persistent data path.
Alpine uses musl; Debian and Debian slim use glibc. Distroless uses Debian 13 and
contains no shell or package manager. Each image contains the compiled server,
required system libraries, and CA certificates, without project dependencies or
a separate Bun installation.
The exec-form health check runs /app/bunqueue healthcheck. It requests
http://127.0.0.1:$HTTP_PORT/health (default port 6790), requires a healthy JSON
response, and fails after five seconds. It works with server authentication enabled.
If you change the listener using a config file or CLI flags, use a custom hostname,
or enable HTTPS, override the probe with the matching URL. For distroless, use a
derived Dockerfile with JSON exec form:
FROM egeominotti/bunqueue:2.9.5-distrolessHEALTHCHECK CMD ["/app/bunqueue", "healthcheck", "https://broker.example:8443/health"]HTTPS probes verify certificates. The URL must match the certificate and use a
trusted CA. The HTTP probe does not support Unix sockets; socket-only deployments
must configure their own health check. Bind-mounted data directories must be
writable by UID 1001.
To package your own application with bunqueue, build an image from your project:
FROM oven/bun:1.4.2-alpine
WORKDIR /appCOPY package.json bun.lock* ./RUN bun install --frozen-lockfile --productionCOPY . .RUN mkdir -p /app/data
ENV BUNQUEUE_DATA_PATH=/app/data/bunq.dbENV NODE_ENV=production
# wget ships with the Alpine base, curl does notHEALTHCHECK --interval=30s --timeout=3s \ CMD wget --spider -q http://127.0.0.1:6790/health || exit 1
EXPOSE 6789 6790# "start" is your own package.json script (e.g. "bunqueue start")CMD ["bun", "run", "start"]services: bunqueue: build: . ports: - '6789:6789' # TCP (SDK clients) - '6790:6790' # HTTP (health, metrics, REST) volumes: - bunqueue-data:/app/data environment: - BUNQUEUE_DATA_PATH=/app/data/bunq.db - AUTH_TOKENS=${AUTH_TOKENS} - S3_BACKUP_ENABLED=1 - S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID} - S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY} - S3_BUCKET=${S3_BUCKET} - S3_REGION=${S3_REGION} restart: unless-stopped deploy: resources: limits: memory: 512M
volumes: bunqueue-data:systemd
Section titled “systemd”For bare-metal or VM deployments:
[Unit]Description=bunqueue Job QueueAfter=network.target
[Service]Type=simpleUser=bunqueueGroup=bunqueueWorkingDirectory=/var/lib/bunqueueExecStart=/usr/local/bin/bunqueue startRestart=alwaysRestartSec=5
Environment=NODE_ENV=productionEnvironment=BUNQUEUE_DATA_PATH=/var/lib/bunqueue/bunq.dbEnvironmentFile=/etc/bunqueue.env
NoNewPrivileges=trueProtectSystem=strictProtectHome=trueReadWritePaths=/var/lib/bunqueue
MemoryMax=512M
[Install]WantedBy=multi-user.targetsudo systemctl daemon-reloadsudo systemctl enable --now bunqueuesudo journalctl -u bunqueue -fTo upgrade: stop the service, bun install -g bunqueue@latest, start it again, then confirm with curl http://localhost:6790/health | jq .version. Updating the package does not restart the service.
Build the standalone binary first (bun run build in the repo produces dist/bunqueue, a self-contained executable that bundles the Bun runtime), or install globally.
module.exports = { apps: [ { name: 'bunqueue', script: '/usr/local/bin/bunqueue', args: 'start', instances: 1, // required for SQLite; PostgreSQL brokers use unique IDs exec_mode: 'fork', autorestart: true, max_memory_restart: '512M', env: { NODE_ENV: 'production', DATA_PATH: '/var/lib/bunqueue/bunq.db', TCP_PORT: 6789, HTTP_PORT: 6790, }, }, ],};pm2 start ecosystem.config.jspm2 save && pm2 startup # survive rebootsHealth checks
Section titled “Health checks”The HTTP port (default 6790) serves everything an orchestrator needs:
curl http://localhost:6790/health # detailed: status, version, job counts, memorycurl http://localhost:6790/healthz # bare liveness, returns OKcurl http://localhost:6790/ready # readinesscurl http://localhost:6790/prometheus # metrics in Prometheus text format/health and /ready return 503 when persistent storage is degraded (including
SQLite disk-full and PostgreSQL runtime errors); /healthz remains a pure
process-liveness check. PostgreSQL SQLSTATE, constraint, host, driver, and
network details stay in local diagnostics: client-facing health payloads use
Internal server error, while SQLite disk-full retains its actionable message.
If METRICS_AUTH=true, also
configure AUTH_TOKENS and the scraper bearer token; otherwise /prometheus
fails closed with 503.
# KuberneteslivenessProbe: httpGet: { path: /healthz, port: 6790 } initialDelaySeconds: 5 periodSeconds: 10readinessProbe: httpGet: { path: /ready, port: 6790 } initialDelaySeconds: 5 periodSeconds: 5Alerting thresholds and dashboards are covered in Monitoring and Production Operations.
SQLite backups and restore
Section titled “SQLite backups and restore”The whole queue is one SQLite file, so back up that one file. The built-in S3 backup uploads periodic snapshots (works with AWS S3, Cloudflare R2, MinIO):
S3_BACKUP_ENABLED=1S3_BUCKET=my-bunqueue-backupsS3_ACCESS_KEY_ID=...S3_SECRET_ACCESS_KEY=...S3_REGION=us-east-1 # or S3_ENDPOINT for R2/MinIOS3_BACKUP_INTERVAL=3600000 # every hour (default 6h)S3_BACKUP_RETENTION=24 # snapshots keptThe persistent data path configured earlier is mandatory. The server flushes
its pending write buffer, then asks SQLite for a VACUUM INTO snapshot that
includes committed WAL frames; do not replace this with a raw live-file copy.
To restore after a disk loss:
systemctl stop bunqueuebunqueue backup listbunqueue backup restore backups/bunq-2026-01-30T12:00:00.db --forcesystemctl start bunqueueProvider-specific setup (R2, MinIO, Spaces) and how restore verifies SHA-256, checks SQLite integrity, and quarantines stale sidecars before replacing anything: S3 Backup guide.
For PostgreSQL, use the database provider’s backup, replication, and
point-in-time-recovery facilities. S3_BACKUP_ENABLED=1 with the PostgreSQL
driver is rejected at startup.
Reference: key environment variables
Section titled “Reference: key environment variables”| Variable | What it does | Default |
|---|---|---|
BUNQUEUE_STORAGE_DRIVER | memory, sqlite, or postgres | inferred |
BUNQUEUE_DATA_PATH | SQLite file path (aliases, in priority order: BQ_DATA_PATH, DATA_PATH, SQLITE_PATH) | in-memory |
BUNQUEUE_POSTGRES_URL | PostgreSQL server connection URL | none |
BUNQUEUE_POSTGRES_NAMESPACE | Shared installation namespace | default |
BUNQUEUE_BROKER_ID | Unique PostgreSQL broker identity | generated |
BUNQUEUE_POSTGRES_POOL_SIZE | SQL connections per broker; budget brokers × poolSize plus operational headroom | 4 |
BUNQUEUE_POSTGRES_LEASE_DURATION_MS | Broker coordination and recovery cadence input | 30000 |
BUNQUEUE_POSTGRES_POLL_INTERVAL_MS | Durable-event and cron fallback polling interval | 250 |
BUNQUEUE_POSTGRES_STATEMENT_TIMEOUT_MS | Maximum SQL statement duration | 30000 |
BUNQUEUE_POSTGRES_LOCK_TIMEOUT_MS | Maximum wait for a PostgreSQL lock | 5000 |
BUNQUEUE_POSTGRES_IDLE_TRANSACTION_TIMEOUT_MS | Maximum idle time inside a transaction | 30000 |
BUNQUEUE_POSTGRES_MAX_CONCURRENT_OPERATIONS | Active PostgreSQL manager operations per broker | 16 |
BUNQUEUE_POSTGRES_MAX_QUEUED_OPERATIONS | Waiting operations before fail-fast saturation | 128 |
TCP_PORT | Port for SDK clients | 6789 |
HTTP_PORT | Port for health, metrics, REST | 6790 |
AUTH_TOKENS | Comma-separated tokens clients must present | none (open) |
TLS_CERT_FILE / TLS_KEY_FILE | PEM cert and key for native TLS on TCP and HTTP. Set both or neither; setting only one is a startup error | off |
S3_BACKUP_ENABLED | Turn on S3 snapshots | 0 |
SHUTDOWN_TIMEOUT_MS | How long a graceful shutdown waits for active jobs | 30000 |
The full list, including all S3 and cloud variables, lives in Environment Variables. Server CLI flags include bunqueue start --tcp-port --http-port --data-path --max-completed-jobs --completed-retention-ms --auth-tokens --tls-cert --tls-key.
Sizing by backend
Section titled “Sizing by backend”There is no backend-independent CPU/RAM table: retained payload size, command batching, worker concurrency, journal retention, database latency, and observability history all change the resource curve. Measure admission, processing, and complete lifecycle separately with production-shaped payloads.
For memory/SQLite, size the bunqueue process for its in-memory indexes and
retained jobs, and place the database on durable low-latency storage. Set
removeOnComplete: true when completed rows do not need to remain queryable, and
keep enough failed jobs for diagnosis. One SQLite file still has exactly one
broker owner; adding broker processes is not a scaling mechanism.
For PostgreSQL, budget two layers independently:
- Bun brokers: memory for bounded compatibility snapshots, active workers, connections, and command batches. Start with the default pool of four per broker and the default 16 active/128 queued operation admission limits.
- Database:
brokers × poolSizeapplication connections plus administration, monitoring, replication, and failover headroom. Size CPU,shared_buffers, WAL, storage IOPS, autovacuum, and replica replay capacity from measured churn.
The native engineering campaign found two brokers faster than one for its fixed 16-consumer workload, while four brokers added availability but not linear throughput. Treat that as a contention warning, not a universal broker-count recommendation. See PostgreSQL benchmark evidence.
Gotchas
Section titled “Gotchas”- SQLite is single-broker. Horizontal broker scaling requires PostgreSQL 15–18 mode; 18.6 is recommended. PostgreSQL/database HA, routing, and backup remain operational responsibilities; bunqueue is not a multi-region consensus system.
- Two embedded processes, one file, is silent corruption. See the warning above; always go through the server for multi-process.
- Copying the database while the server runs can produce a torn copy. SQLite runs in WAL mode (writes go to a
.db-walsidecar file first). Stop the server, then copybunq.dbandbunq.db-waltogether, or just use the built-in S3 backup, which snapshots safely while live. AUTH_TOKENSis not set by default. Anyone who can reach the ports can push and pull jobs. Set tokens (openssl rand -hex 32) and either enable native TLS or keep the ports on a private network. See Security.- SQLite jobs that cannot tolerate bunqueue’s process-crash buffer should be
durable: true. SQLite buffers ordinary writes for up to 10 ms;durablecommits before the add returns. Host/filesystem/media durability remains operational. PostgreSQL mutations are already transactional and do not use this buffer. Details in Production Operations. - Do not improvise a mixed-version PostgreSQL rollout. Stop the old broker fleet before the first new binary migrates the schema, verify one new broker, then start the rest at the same version. An old binary may reject the newer recorded schema; use roll-forward or a coordinated database/PITR restore for rollback. See PostgreSQL upgrades.