Getting started
Prerequisites
- Node.js 18+
- PostgreSQL (same database for pg-boss and app migrations)
Install
git clone https://github.com/blockqueue/bq-queue.git
cd bq-queue
npm install
cd apps/queue-serviceEnvironment
cp .env.example .envSet these (names matter; .env.example may show DATABASE_URL, but the service reads POSTGRES_DATABASE_URL):
PORT=9000
NODE_ENV=development
SCHEDULER_CONFIG_DIR=./config
POSTGRES_DATABASE_URL=postgresql://user:pass@localhost:5432/bq_queue
POSTGRES_SSL=false
REQUEST_SIGNING_SECRET=replace-with-a-long-random-secret
SIGNATURE_HEADER=x-bq-queue-request-signaturePORT must be between 3000 and 65535.
Queue config
Copy a sample and point real endpoints/secrets at it. Do not leave *.sample.yml in the config directory if you also keep production files there. Every *.yml / *.yaml file is loaded.
cp config/prj-a-config.sample.yml config/prj-a.ymlMinimal queue:
queues:
WEBHOOK_DELIVERY:
endpoint: 'http://127.0.0.1:4000/hooks/deliver'
method: 'POST'
signature_secret: ${WEBHOOK_SIGNING_SECRET}
max_concurrent_jobs: 5
retryLimit: 3
retryDelay: 30Export the queue secret in the same shell/env as the service:
export WEBHOOK_SIGNING_SECRET=another-long-random-secretMore YAML options: Configuration.
Migrate, build, run
npm run db:apply:migration
npm run build
npm run startHealth check (no signature):
curl -s http://127.0.0.1:9000/api/health
# {"ok":true}Docker Compose in the repo root can start Postgres, the service, and a dashboard for queues, jobs, and schedules. Mount your YAML dir and set SCHEDULER_CONFIG_DIR (often /config in the image).
Enqueue your first job
Job bodies for one-off and schedule are JSON arrays. Sign the exact bytes you send.
Node example:
import crypto from 'node:crypto';
const secret = process.env.REQUEST_SIGNING_SECRET;
const body = JSON.stringify([
{
idempotencyKey: 'invoice-42-send',
queue: 'WEBHOOK_DELIVERY',
payload: { invoiceId: 42 },
},
]);
const t = Math.floor(Date.now() / 1000);
const v1 = crypto
.createHmac('sha512', secret)
.update(`${t}.`)
.update(body)
.digest('hex');
const res = await fetch('http://127.0.0.1:9000/api/jobs/one-off', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-bq-queue-request-signature': `t=${t},v1=${v1}`,
},
body,
});
console.log(res.status, await res.json());
// 202 { jobs: [ { jobId: '...' } ] }Handle the delivery (same machine)
Point the queue endpoint at a small listener (port 4000 in the YAML above). Use the queue secret (WEBHOOK_SIGNING_SECRET), not REQUEST_SIGNING_SECRET.
import crypto from 'node:crypto';
import express from 'express';
function verifySignature(rawBody, headerValue, secret) {
const match = /^t=(\d+),v1=([a-f0-9]+)$/i.exec(headerValue ?? '');
if (!match) return false;
const t = Number(match[1]);
const v1 = match[2];
if (Math.abs(Math.floor(Date.now() / 1000) - t) > 300) return false;
const expected = crypto
.createHmac('sha512', secret)
.update(`${t}.`)
.update(rawBody)
.digest('hex');
try {
return crypto.timingSafeEqual(
Buffer.from(v1, 'hex'),
Buffer.from(expected, 'hex'),
);
} catch {
return false;
}
}
const app = express();
const QUEUE_SECRET = process.env.WEBHOOK_SIGNING_SECRET;
app.post(
'/hooks/deliver',
express.raw({ type: 'application/json' }),
(req, res) => {
const raw = req.body.toString('utf8');
const header = req.headers['x-bq-queue-request-signature'];
if (!verifySignature(raw, header, QUEUE_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { data } = JSON.parse(raw);
console.log('job payload', data); // { invoiceId: 42 }
return res.status(200).json({ ok: true });
},
);
app.listen(4000);Start this before (or while) you enqueue. Non-2xx responses are retried. More detail: Receiving jobs and Request signing.
Example: publish later
Your CMS schedules a post. It does not sleep in-process or rely on a replica’s cron. Add a POST_PUBLISHING queue in YAML (same shape as WEBHOOK_DELIVERY above), then enqueue with runAt:
import crypto from 'node:crypto';
const secret = process.env.REQUEST_SIGNING_SECRET;
const postId = 42;
const body = JSON.stringify([
{
idempotencyKey: `post-${postId}-publish`,
queue: 'POST_PUBLISHING',
payload: { postId, action: 'publish' },
runAt: '2026-03-23T15:00:00Z',
},
]);
const t = Math.floor(Date.now() / 1000);
const v1 = crypto
.createHmac('sha512', secret)
.update(`${t}.`)
.update(body)
.digest('hex');
await fetch('http://127.0.0.1:9000/api/jobs/one-off', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-bq-queue-request-signature': `t=${t},v1=${v1}`,
},
body,
});At runAt, queue-service POSTs your POST_PUBLISHING endpoint with { timestamp, data: { postId, action: 'publish' } }. Your handler publishes and returns 200. Resubmitting the same idempotencyKey cancels the previous job and enqueues a new one (useful if the user changes the publish time).
Example: daily digest (YAML cron)
Fixed recurring work stays in config. No API call every morning:
queues:
REPORT_DIGEST:
endpoint: 'http://127.0.0.1:4000/hooks/digest'
method: 'POST'
signature_secret: ${REPORT_DIGEST_SIGNING_SECRET}
cron_jobs:
- name: 'daily-report-digest'
queue: 'REPORT_DIGEST'
schedule: '0 8 * * *'
timezone: 'UTC'
payload:
type: 'daily_digest'Every day at 08:00 UTC the worker hits /hooks/digest with data: { type: 'daily_digest' }. Verify with REPORT_DIGEST_SIGNING_SECRET. Change the schedule by editing YAML and restarting the service.
For “next Monday only” from app logic, use POST /api/jobs/schedule instead. Details: Configuration and HTTP API.
Images and deploy
Published images (tag releases):
docker pull ghcr.io/blockqueue/bq-queue:<tag>
# optional UI
docker pull ghcr.io/blockqueue/bq-queue-ui:<tag>In-repo examples:
- Root
docker-compose.yml: Postgres + queue-service + dashboard for local work docker/docker-compose.serve.yml: pull a publishedghcr.io/blockqueue/bq-queuetag, mount YAML at/config, setSCHEDULER_CONFIG_DIR=/config
Run on any orchestrator you use (Compose, Swarm, Kubernetes, and so on). Typical layout: queue-service on an internal network with one or more replicas, Postgres reachable only from that network, app services as queue endpoint hosts (service DNS names), secrets via env or your secret store, config as a mounted directory or config object. Put TLS and access control at the edge if anything must cross trust boundaries. Keep the dashboard on a private address.