# Deploy bunqueue: SQLite or PostgreSQL 15–18

Deploy bunqueue with embedded or single-broker SQLite, or a PostgreSQL 15–18 multi-broker topology, including Kubernetes, Docker, health, and backups.

Canonical: https://bunqueue.dev/guide/deployment/

---

import { Aside } from '@astrojs/starlight/components';

<div class="bq-wrap bq-hero">
  <span class="bq-eyebrow">guide · deployment</span>
  <h1 class="bq-hero-h1 bq-bench-h1">From laptop to <em>production.</em></h1>
  <p class="bq-hero-sub">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.</p>
</div>

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

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:

```typescript
// 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.

<Aside type="caution">
  Without `dataPath` (or the `BUNQUEUE_DATA_PATH` environment variable), the embedded queue keeps
  jobs **in memory only** and loses them on restart.
</Aside>

<Aside type="tip" title="Prefer a config file?">
You can centralize settings in a typed `bunqueue.config.ts` instead of environment variables:

```typescript
import { defineConfig } from 'bunqueue';

export default defineConfig({
  storage: { dataPath: './data/bunq.db' },
  auth: { tokens: [process.env.AUTH_TOKEN!] },
  backup: { enabled: true, bucket: 'my-backups' },
});
```

See [Configuration File](/guide/configuration/).

</Aside>

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

```bash
bunqueue start --data-path ./data/bunq.db
```

```typescript
// 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: '...' });
```

```typescript
// 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:

```bash
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"}}'
```

<Aside type="danger" title="Never share one SQLite file between two embedded processes">
  Two processes with `embedded: true` pointing at the same file do **not** work. Each keeps its own
  in-memory state and reads the file only at startup, so they never see each other's jobs and both
  write to the same file. Multiple clients always go through a server; multiple active servers
  require PostgreSQL mode.
</Aside>

## Multiple active brokers: PostgreSQL 15–18

Use the repository's pinned Compose topology when one broker is not enough:

```bash
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:

```bash
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](/guide/databases/) for the lease and consistency model.

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

```yaml
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.

<Aside type="caution" title="Coordinate upgrades; do not use a mixed-version rollout">
  `Recreate` prevents old and new broker binaries from overlapping, but causes a fleet-wide outage
  during an image update. Stop the old fleet before a new binary migrates the schema, verify one new
  Pod, and only then restore the desired replica count. Follow the complete
  [PostgreSQL schema upgrade and rollback procedure](/guide/production/#postgresql-schema-upgrades).
</Aside>

<Aside type="note" title="Validated Kubernetes failure campaign">
  On 2026-08-27 this topology was exercised in a fresh kind v0.33.0 cluster running
  Kubernetes 1.33.1, the repository Bun 1.4.0 image, PostgreSQL 18.6, and four broker Pods.
  The campaign completed 1,000 jobs admitted and consumed through all four brokers, a four-job
  cross-broker `FlowProducer` graph, and 120 more jobs after force-deleting the Pod that owned 24
  active leases. All 24 leases were recovered, stale tokens were fenced, and the Deployment returned
  to four Ready Pods. PostgreSQL ended with 1,124 completed rows, no remaining active/waiting jobs,
  zero deadlocks, and zero temporary-file bytes. This is functional failure evidence, not a capacity
  benchmark or PostgreSQL HA test; the single disposable database was intentionally not failed.
</Aside>

## Docker

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

```bash
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:

```dockerfile
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:

```dockerfile
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"]
```

```yaml
# 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:
```

## systemd

For bare-metal or VM deployments:

```ini
# /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
```

```bash
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.

## PM2

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.

```javascript
// 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,
      },
    },
  ],
};
```

```bash
pm2 start ecosystem.config.js
pm2 save && pm2 startup   # survive reboots
```

<Aside type="caution">
  Never use PM2 cluster mode with SQLite. Multiple processes writing the same file are unsupported.
  PostgreSQL mode can run multiple brokers, but configure a unique `BUNQUEUE_BROKER_ID` per instance
  rather than cloning one static ID.
</Aside>

## Health checks

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

```bash
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.

```yaml
# 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](/guide/monitoring/) and [Production Operations](/guide/production/).

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

```bash
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:

```bash
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](/guide/backup/).

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

| 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](/guide/env-vars/). 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

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](/guide/benchmarks/#postgresql-1518-multi-broker).

## 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-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](/guide/tls/) or keep the ports on a private network. See [Security](/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](/guide/production/).
- **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](/guide/production/#postgresql-schema-upgrades).