Move slow work off your request path and onto a worker, and the queue between the two is usually the part that goes undocumented. This walks through provisioning a Valkey cache as the queue backend, deploying a worker to consume it, scaling that worker, and where a scheduled job fits instead.
TL;DR
- A worker deployment is a long-running background process with no public endpoint, the right shape for a queue consumer.
- A Valkey cache (Redis-compatible) is the queue backend. It is always reserved, not metered, starting at $2/mo for a Cache 0.25 box.
- A producer (your web app) pushes jobs onto the queue; a consumer (the worker) pulls them off. BullMQ is a common choice on Node, and the same pattern holds for Celery or RQ on Python.
- Turn on horizontal autoscaling on the worker to add replicas under queue pressure and scale back down when it drains.
- A worker is always running and drains continuously. A scheduled job runs to completion on a cron schedule and exits. Pick based on whether the work is continuous or periodic.
Step 1: create the Valkey cache
From your dashboard, add a new deployment and choose Cache as the resource type. Pick the smallest box to start:
| Box | vCPU | Memory | Price |
|---|---|---|---|
| Cache 0.25 | 0.1 vCPU (shared) | 0.25 GiB | $2/mo |
| Cache 0.5 | 0.1 vCPU (shared) | 0.5 GiB | $3/mo |
| Cache 1 | 0.25 vCPU (shared) | 1 GiB | $5/mo |
Caches are always reserved, so the price is flat for as long as the cache exists, busy or idle. There is no storage charge because a cache is in-memory and ephemeral: a restart clears it, which is fine for a job queue where in-flight jobs are re-enqueued or lost gracefully, but worth knowing before you point anything at it that needs durability.
Step 2: get the connection string
Open the cache’s Connection details card. It shows the host, port, password, and a ready-to-use redis:// connection string, plus a redis-cli one-liner for a quick manual check. The cache is private by default, reachable only from your own deployments on the private network; there is no public endpoint unless you opt in.
Copy the connection string and set it as a Secret environment variable, REDIS_URL, on both the deployment that will enqueue jobs and the worker that will process them. See how to manage secrets and environment variables if you have not set one before.
Step 3: write a producer
Anywhere in your existing web app that currently does slow work inline, for example sending an email or generating a report, push a job onto the queue instead of doing the work in the request:
// queue.ts
import IORedis from "ioredis";
import { Queue } from "bullmq";
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
export const emailQueue = new Queue("emails", { connection });
// in your request handler
await emailQueue.add("welcome", { userId: user.id, email: user.email });
res.status(202).json({ queued: true });
The request returns immediately. The email itself gets sent by the consumer.
Step 4: deploy the worker
Create a new deployment and choose Worker as the resource type. A worker has no public hostname; it just runs. Point it at an image that starts your consumer process, and set the same REDIS_URL secret from Step 2:
// worker.ts
import IORedis from "ioredis";
import { Worker } from "bullmq";
const connection = new IORedis(process.env.REDIS_URL!, {
maxRetriesPerRequest: null,
});
new Worker(
"emails",
async (job) => {
await sendWelcomeEmail(job.data.email);
},
{ connection, concurrency: 5 }
);
Deploy it, and check the worker’s Logs tab to confirm it connected and is pulling jobs. If you are on Python, the same shape works with Celery (Valkey as the broker) or RQ; only the client library changes, since Valkey speaks the Redis protocol.
Step 5: scale the worker
A single worker replica processing five jobs at a time is fine until the queue backs up faster than it drains. Turn on autoscaling on the worker deployment from its Settings tab, the same feature web deployments use:
- Min replicas: keep at least one worker running, so the queue is never unattended.
- Max replicas: a ceiling, up to 100.
- Target CPU utilization: scale up once average CPU crosses your target (queue consumers are often CPU-bound during processing and idle between jobs, so this responds well to a burst of work).
Autoscaling reacts within seconds of a sustained change and removes replicas again once usage drops, so a burst of signups that floods the email queue gets more workers automatically, and you are not paying for five replicas sitting idle overnight.
Worker versus scheduled job
Both run on Ownkube, and it is easy to reach for the wrong one.
A worker is a long-running process. It starts once and keeps running, continuously pulling from the queue as jobs arrive. Use it for anything continuous: queue consumers, event processors, a WebSocket relay.
A scheduled job runs a container to completion and exits, on a cron schedule you set when you create it. It has its own Runs tab with recent run history, a Run now button to fire an off-schedule run, and per-run controls for retries, timeouts, and overlap handling. Use it for periodic work that does not need a process sitting around between runs: a nightly export, a weekly cleanup, a report that regenerates every hour.
If your workload is “do this continuously as items arrive,” it is a worker. If it is “do this once, on a schedule,” it is a job. A queue consumer is almost always a worker, since jobs arrive at unpredictable times and something needs to be listening.
FAQ
Does the worker scale to zero when the queue is empty?
No. Scale-to-zero is not available yet, so the floor is one replica once autoscaling is on. If you want a worker to stop entirely between bursts, pause it to 0 replicas manually; autoscaling will not do that for you.
Do I have to use BullMQ?
No. Valkey is Redis-compatible, so any Redis-protocol queue library works: BullMQ or a raw Redis list on Node, Celery or RQ with a Redis broker on Python, Sidekiq on Ruby. The cache does not care which client connects to it.
What happens to queued jobs if the cache restarts?
A Valkey cache is in-memory and ephemeral, so a restart clears it. Most queue libraries handle in-flight job loss by design (BullMQ jobs that were claimed but not acknowledged become stalled and get retried), but anything already fully processed and acknowledged is gone. Do not use the cache as a durable store for jobs you cannot afford to lose; use it as a queue.
Can I reach the cache from outside Ownkube?
Not by default. A cache is private, reachable only from your own deployments, unless you turn on public access, which allocates a TLS-encrypted rediss:// endpoint.
How is a cache priced compared to a metered worker?
Differently, on purpose. The worker is metered: CPU and memory bill on actual per-minute usage, so it costs little while idle between bursts. The cache is reserved: a flat monthly price for the box you picked, whether it is busy or idle, because it holds memory continuously. Both draw the same prepaid wallet.
Where Ownkube fits
A queue and a worker are a small amount of infrastructure, but they are exactly the kind of thing that is annoying to hand-wire: provisioning a cache, getting the connection string into two different deployments, and getting autoscaling right so a traffic spike does not silently pile up in the queue. On Ownkube it is three deployments (web, cache, worker) and one setting to flip. The same setup runs the same way if you later move it into your own AWS account; see our comparison of Railway, Render, and Northflank for how queue and worker pricing stacks up elsewhere. Deploy your first app.