Skip to content
Get started
Get started
Deploy bunqueue: SQLite or PostgreSQL 15–18
View Markdown
guide · deployment

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.

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:

app.ts
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.

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:

Terminal window
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 independently
import { 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:

Terminal window
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:

Terminal window
POSTGRES_PASSWORD='replace-me' \
BUNQUEUE_POSTGRES_URL='postgres://bunqueue:replace-me@postgres:5432/bunqueue' \
docker compose -f docker-compose.postgres.yml up --build -d

This 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:

Terminal window
BUNQUEUE_STORAGE_DRIVER=postgres
BUNQUEUE_POSTGRES_URL='postgres://bunqueue:percent-encoded-password@postgres:5432/bunqueue'
BUNQUEUE_POSTGRES_NAMESPACE=production
BUNQUEUE_BROKER_ID=broker-a # unique for every active process

Compose 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: v1
kind: Secret
metadata:
name: bunqueue
type: Opaque
stringData:
# 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: v1
kind: Service
metadata:
name: bunqueue
spec:
selector:
app.kubernetes.io/name: bunqueue
ports:
- name: tcp
port: 6789
targetPort: tcp
- name: http
port: 6790
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bunqueue
spec:
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/v1
kind: PodDisruptionBudget
metadata:
name: bunqueue
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: bunqueue

The 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.

For the prebuilt server, use Docker Hub (available from 2.9.5):

Terminal window
docker run -d --name bunqueue \
-p 6789:6789 -p 6790:6790 \
-v bunqueue-data:/app/data \
egeominotti/bunqueue:2.9.5

The 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-distroless
HEALTHCHECK 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 /app
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
COPY . .
RUN mkdir -p /app/data
ENV BUNQUEUE_DATA_PATH=/app/data/bunq.db
ENV NODE_ENV=production
# wget ships with the Alpine base, curl does not
HEALTHCHECK --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"]
docker-compose.yml
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:

For bare-metal or VM deployments:

/etc/systemd/system/bunqueue.service
[Unit]
Description=bunqueue Job Queue
After=network.target
[Service]
Type=simple
User=bunqueue
Group=bunqueue
WorkingDirectory=/var/lib/bunqueue
ExecStart=/usr/local/bin/bunqueue start
Restart=always
RestartSec=5
Environment=NODE_ENV=production
Environment=BUNQUEUE_DATA_PATH=/var/lib/bunqueue/bunq.db
EnvironmentFile=/etc/bunqueue.env
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/bunqueue
MemoryMax=512M
[Install]
WantedBy=multi-user.target
Terminal window
sudo systemctl daemon-reload
sudo systemctl enable --now bunqueue
sudo journalctl -u bunqueue -f

To 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.

ecosystem.config.js
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,
},
},
],
};
Terminal window
pm2 start ecosystem.config.js
pm2 save && pm2 startup # survive reboots

The HTTP port (default 6790) serves everything an orchestrator needs:

Terminal window
curl http://localhost:6790/health # detailed: status, version, job counts, memory
curl http://localhost:6790/healthz # bare liveness, returns OK
curl http://localhost:6790/ready # readiness
curl 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.

# Kubernetes
livenessProbe:
httpGet: { path: /healthz, port: 6790 }
initialDelaySeconds: 5
periodSeconds: 10
readinessProbe:
httpGet: { path: /ready, port: 6790 }
initialDelaySeconds: 5
periodSeconds: 5

Alerting thresholds and dashboards are covered in Monitoring and Production Operations.

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):

Terminal window
S3_BACKUP_ENABLED=1
S3_BUCKET=my-bunqueue-backups
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_REGION=us-east-1 # or S3_ENDPOINT for R2/MinIO
S3_BACKUP_INTERVAL=3600000 # every hour (default 6h)
S3_BACKUP_RETENTION=24 # snapshots kept

The 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:

Terminal window
systemctl stop bunqueue
bunqueue backup list
bunqueue backup restore backups/bunq-2026-01-30T12:00:00.db --force
systemctl start bunqueue

Provider-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.

VariableWhat it doesDefault
BUNQUEUE_STORAGE_DRIVERmemory, sqlite, or postgresinferred
BUNQUEUE_DATA_PATHSQLite file path (aliases, in priority order: BQ_DATA_PATH, DATA_PATH, SQLITE_PATH)in-memory
BUNQUEUE_POSTGRES_URLPostgreSQL server connection URLnone
BUNQUEUE_POSTGRES_NAMESPACEShared installation namespacedefault
BUNQUEUE_BROKER_IDUnique PostgreSQL broker identitygenerated
BUNQUEUE_POSTGRES_POOL_SIZESQL connections per broker; budget brokers × poolSize plus operational headroom4
BUNQUEUE_POSTGRES_LEASE_DURATION_MSBroker coordination and recovery cadence input30000
BUNQUEUE_POSTGRES_POLL_INTERVAL_MSDurable-event and cron fallback polling interval250
BUNQUEUE_POSTGRES_STATEMENT_TIMEOUT_MSMaximum SQL statement duration30000
BUNQUEUE_POSTGRES_LOCK_TIMEOUT_MSMaximum wait for a PostgreSQL lock5000
BUNQUEUE_POSTGRES_IDLE_TRANSACTION_TIMEOUT_MSMaximum idle time inside a transaction30000
BUNQUEUE_POSTGRES_MAX_CONCURRENT_OPERATIONSActive PostgreSQL manager operations per broker16
BUNQUEUE_POSTGRES_MAX_QUEUED_OPERATIONSWaiting operations before fail-fast saturation128
TCP_PORTPort for SDK clients6789
HTTP_PORTPort for health, metrics, REST6790
AUTH_TOKENSComma-separated tokens clients must presentnone (open)
TLS_CERT_FILE / TLS_KEY_FILEPEM cert and key for native TLS on TCP and HTTP. Set both or neither; setting only one is a startup erroroff
S3_BACKUP_ENABLEDTurn on S3 snapshots0
SHUTDOWN_TIMEOUT_MSHow long a graceful shutdown waits for active jobs30000

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.

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 × poolSize application 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.

  • 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-wal sidecar file first). Stop the server, then copy bunq.db and bunq.db-wal together, or just use the built-in S3 backup, which snapshots safely while live.
  • AUTH_TOKENS is 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; durable commits 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.