SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

NATS vs RabbitMQ vs Kafka on one VPS

Which message queue belongs on a single server: delivery guarantees, memory and disk cost, restart behaviour, backlog checks, and when Postgres wins.

The short answer for one server

A message queue on one VPS is a decision about delivery guarantees, not about speed. On a single box the broker is rarely the bottleneck, because your application code, your database and your one disk get there first. Pick the tool whose failure behaviour you are willing to live with, then measure the box you actually have.

Four options, in the order most readers should consider them.

  • Use the database you already run. Postgres with SELECT ... FOR UPDATE SKIP LOCKED is a working job queue, and it adds no new process to monitor.
  • Use RabbitMQ when each message is a unit of work that must be acknowledged, retried a bounded number of times, then parked somewhere a human can look at it.
  • Use NATS when messages are events that several parts of your system react to. Turn on JetStream for the events that must survive a restart.
  • Use Kafka when a downstream tool speaks only the Kafka protocol. On one server that is close to the only reason left.

The rest of this guide gives the reasoning: what each option costs in memory and disk on a small VPS, what it does when the box reboots, and the exact command that shows you a backlog before your users feel it.

What a delivery guarantee actually means

At most once means the broker hands the message over and forgets it. If no consumer is connected, or a consumer dies halfway through the work, the message is gone and nothing reports it.

At least once means the consumer sends an acknowledgement (an ack) after the work succeeds. Until that ack arrives, the broker keeps the message and will deliver it again. Redelivery is why your handlers must be idempotent: processing the same message twice must not charge the card twice. Exactly once, end to end, is not something a broker gives you. It comes from a unique key in your own database.

Replay is a separate property. A queue drops a message once it is acknowledged. A log keeps it for a retention window, so a new consumer can start at the beginning and read the whole history. Kafka and NATS JetStream are logs. RabbitMQ is a queue. That difference shapes more architectures than throughput does.

Dead lettering is what happens to a message that keeps failing. Without it, a poison message loops forever, and the loop looks like a busy worker rather than a broken one.

Start with Postgres and make the broker prove itself

Most single-application workloads are a few thousand background jobs a day. That fits in a table.

CREATE TABLE job (
  id        bigserial   PRIMARY KEY,
  payload   jsonb       NOT NULL,
  run_after timestamptz NOT NULL DEFAULT now(),
  attempts  int         NOT NULL DEFAULT 0
);
CREATE INDEX job_ready_idx ON job (run_after, id);

A worker claims one job inside a transaction.

BEGIN;
SELECT id, payload
  FROM job
 WHERE run_after <= now()
 ORDER BY id
   FOR UPDATE SKIP LOCKED
 LIMIT 1;
-- run the work, then remove the row
DELETE FROM job WHERE id = $1;
COMMIT;

FOR UPDATE SKIP LOCKED is the whole trick. It locks the row it returns and skips any row another transaction has already locked, so two workers never claim the same job. If a worker crashes, Postgres aborts its transaction, the lock is released, and the row becomes visible to the next worker. You get at-least-once delivery, retries by bumping attempts, and a dead letter table, all on durability you already pay for. The backlog is one query: SELECT count(*) FROM job WHERE run_after <= now();

Where it stops working. Every claim and delete is a write, so a high job rate leaves dead row versions behind, and a queue table is the classic case of bloat outrunning autovacuum. Long jobs make it worse, because a transaction held open for the length of the work also holds back the vacuum horizon for the whole database. Polling adds latency, and LISTEN with NOTIFY removes the polling but not the writes. When the job table is the busiest table you have, or a second service needs the same events, move the work out. That choice interacts with how the database itself is deployed, so settle whether the database runs in Docker or on the host before you add a broker next to it.

Redis is the other thing you may already run. Redis Streams give you consumer groups with XADD and XREADGROUP, a pending list per group, and XAUTOCLAIM to take work back from a consumer that died. It is small and quick. The honest catch on one box: with the common appendfsync everysec setting, a power loss can lose about a second of writes. That is fine for cache invalidation and wrong for payments. If your application is a single process built around SQLite in production on a VPS, the same claim-and-delete pattern works, though SQLite has no equivalent of SKIP LOCKED and every worker serialises on the one write lock.

NATS core: subject routing with no memory

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  nats:2.14 -m 8222

As of August 2026 the current server line is 2.14. -m 8222 turns on the HTTP monitoring port, which is off by default and has no authentication, so bind it to localhost as above.

Core NATS is at most once and it stores nothing. A publisher sends to a subject such as orders.created, and every subscriber whose filter matches gets a copy. If nobody is subscribed, the message is dropped and the publisher sees no error, because the publisher's job ended when the server accepted the bytes. A queue group (several subscribers sharing one group name) makes the server pick one member per message, which shares work without storing a queue.

Footprint is subscription state plus a write buffer for each connection, so it tracks connection count rather than message volume, and nothing accumulates on disk. Restart behaviour follows from that: in-flight messages are gone, clients reconnect on their own, and there is no recovery step to wait for.

There is no backlog to watch, so watch for loss instead. When a subscriber reads its socket more slowly than the server writes to it, the server's buffer for that client fills. If the client has not caught up by the write deadline, the server closes the whole connection and increments a counter.

curl -s http://localhost:8222/varz | jq '.slow_consumers, .connections, .in_msgs, .out_msgs'

A slow_consumers value that keeps climbing means messages are being dropped, so alert on it rather than reading it once. Core NATS suits a message whose value expires quickly: a metric, a presence update, a cache invalidation that the next event will supersede anyway.

NATS JetStream: durable streams and replay in the same process

JetStream is not a second product. It is a subsystem in the same binary, enabled by one flag.

docker run -d --name nats \
  -p 4222:4222 -p 127.0.0.1:8222:8222 \
  -v nats-data:/data \
  nats:2.14 -js -sd /data -m 8222

-sd /data sets the store directory. Leave it out and JetStream stores its data under /tmp, which is exactly as durable as it sounds. Create a stream with the CLI, which ships in the nats-box image.

docker run --rm -it --network host natsio/nats-box:latest \
  nats stream add ORDERS \
    --subjects 'orders.>' \
    --storage file \
    --retention limits \
    --max-age 72h \
    --max-bytes=1073741824 \
    --discard old \
    --defaults

Every limit there earns its place on a small server. --storage file is what survives a crash, since a memory stream does not. --max-bytes=1073741824 caps the stream at 1 GiB written as a byte count, and --discard old drops the oldest messages when the cap is reached instead of refusing new writes. Leave the cap off and one runaway publisher fills the disk, at which point your database stops too, because they share that disk.

A durable consumer keeps its own position in the stream and keeps it across a restart. Set --max-deliver on the consumer so a message that always fails stops being redelivered forever. When a message runs out of deliveries, JetStream publishes an advisory on $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>, and subscribing to that subject is how you build the dead letter path that RabbitMQ hands you as a feature. That is real work you write yourself.

To see a backlog, run nats stream report for stored message counts and nats consumer report ORDERS for outstanding acknowledgements and unprocessed messages per consumer. The unprocessed number is the one to alarm on. Disk cost is visible with du -sh against the store directory, and it grows until a retention limit trims it.

RabbitMQ: acknowledge each message, park the failures

docker run -d --name rabbitmq \
  -p 5672:5672 -p 127.0.0.1:15672:15672 \
  -v rabbitmq-data:/var/lib/rabbitmq \
  rabbitmq:4-management

As of August 2026 the current series is 4.3. Port 5672 is AMQP (advanced message queuing protocol) and 15672 is the management interface. Keep the interface on localhost and reach it through an SSH tunnel.

Declare queues with the x-queue-type argument set to quorum; the default is still classic. Quorum queues are always durable and write data to disk before doing anything else, so on one node you get a single clear behaviour instead of a matrix of durable and transient options. Set the dead letter target with a policy.

docker exec rabbitmq rabbitmqctl set_policy DLX ".*" \
  '{"dead-letter-exchange":"my-dlx", "dead-letter-routing-key":"my-routing-key"}' \
  --apply-to queues --priority 7

A message is dead lettered for four reasons: a consumer rejects it with basic.reject or basic.nack and requeue set to false, its per-message TTL (time to live) expires, the queue passes a length limit, or it exceeds the quorum queue delivery limit. That limit defaults to 20 from RabbitMQ 4.0 onward, so a handler that throws and nacks retries twenty times and then hands the message to the dead letter exchange rather than looping.

Memory is where RabbitMQ surprises people on a small VPS. The default high watermark is 0.6 of available RAM, and when the node crosses it, RabbitMQ blocks every connection that is publishing. Your application does not receive an error. It receives a publish that never returns, which reads as a hang in your own code. The startup log prints the number the node computed:

Memory high watermark set to 1024 MiB (1073741824 bytes) of 8192 MiB (8589934592 bytes) total

The disk alarm blocks publishers the same way when free space drops below 50 MB by default. Quorum queues add their own arithmetic on top: the documentation budgets at least 32 bytes of in-memory metadata per message, about 1 MB per 30,000 messages, and recommends at least three times the effective write-ahead log size in RAM. The WAL limit defaults to 512 MiB, so that recommendation alone asks for 1.5 GB. On a 2 GB server, lower it in rabbitmq.conf instead of hoping the default fits.

raft.wal_max_size_bytes = 64000000
vm_memory_high_watermark.relative = 0.5

Backlog is two numbers, and the pair tells you which failure you have.

docker exec rabbitmq rabbitmqctl list_queues name messages messages_ready messages_unacknowledged

messages_ready is waiting for a consumer. messages_unacknowledged was delivered and never acked. A rising unacknowledged count next to a flat ready count means your workers took the jobs and stopped finishing them, which is a different bug from a queue that is merely behind.

Kafka on one box, and where it stops making sense

KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)"
bin/kafka-storage.sh format --standalone -t $KAFKA_CLUSTER_ID -c config/server.properties
bin/kafka-server-start.sh config/server.properties

That is the quickstart for Kafka 4.3.1, current as of August 2026, running in KRaft mode (Kafka Raft, the built-in controller that replaced ZooKeeper in Kafka 4.0). The container equivalent is apache/kafka:4.3.1.

The start script sets export KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" when you have not set it yourself, so the broker reserves a 1 GB Java heap before it stores a single message, and it expects free RAM beyond that for the page cache it reads from. On a 2 GB VPS your application is then competing with the JVM for what is left.

Retention is the next surprise. log.retention.hours defaults to 168, which is seven days, and log.retention.bytes defaults to -1, which means no size limit at all. Kafka keeps messages for the whole window whether or not every consumer has read them. That is the feature you came for, and on one small disk it is also the failure mode, so set a byte limit per topic before you find out.

Now the honest part. A single broker means replication factor 1, so acks=all resolves to one fsync on one disk. You get the durability of one machine, with the operating cost of a JVM broker plus a controller. Partitions buy parallelism across brokers you do not have. Replication, rack awareness and the rest of the fleet features sit inert. JetStream gives you the same durable replay on the same box for a fraction of the memory. Two reasons still justify Kafka here: a downstream tool speaks only the Kafka protocol (change data capture with Debezium, or an analytics loader), or you are reproducing a production topology in miniature. Planning to grow into a cluster is a plan to buy more machines, and until then the trade is the same one as running k3s on a single node, where you pay cluster complexity for one node's reliability.

Backlog in Kafka is consumer lag.

bin/kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group my-group

Read the LAG column, which is LOG-END-OFFSET minus CURRENT-OFFSET for each partition. Lag climbing on one partition while the others stay flat points at an uneven key, because all messages with the same key land on the same partition and one consumer handles them alone.

What happens when the box restarts

Core NATS loses everything in flight and comes back instantly, because there is nothing to recover. JetStream reloads streams and consumer positions from the store directory, so consumers resume at the offset they had. RabbitMQ recovers quorum queues from disk, while classic transient queues and any message published without the persistent delivery mode are gone. Kafka replays its log segments on startup, and after an unclean shutdown that recovery scan can take minutes on a small disk before the broker accepts a connection.

Two things are worth setting once. Give the container a restart policy (restart: unless-stopped) or enable the systemd unit, so the broker returns after a kernel upgrade reboot without you. Then handle the order: a broker that becomes ready twenty seconds after your application does will refuse the first connections, and some client libraries exit rather than retry. Gate the app on the broker with Compose healthchecks that hold a dependent service back until the broker is ready.

Cost on your own VPS, measured rather than quoted

Published throughput figures are measured on hardware you do not have, usually a multi-core server with local NVMe. Treat them as an upper bound and measure your box.

docker stats --no-stream
free -m
sudo du -sh /var/lib/docker/volumes/*/_data

Run those with the broker idle, then again under your real traffic. The gap between the two is the number that decides whether the broker fits beside your application. For a rough throughput floor, use each project's own load generator instead of someone's blog post: nats bench pub test --msgs 100000 --clients 2 for NATS, bin/kafka-producer-perf-test.sh for Kafka, and PerfTest for RabbitMQ. Running the generator on the same VPS measures the broker and the generator together, which is fine as long as you say so when you report the number.

One ceiling applies to all of them. Every durable option here waits on fsync, so on a VPS with network-attached storage the disk sets the limit, and swapping brokers will not move it.

Three workloads and the message queue each one wants

  1. Background jobs for one web application, such as sending email, resizing images, or delivering webhooks. Start with Postgres and SKIP LOCKED. Move to RabbitMQ with quorum queues when you want per-message acks, a delivery limit and a dead letter queue you can inspect without writing that logic yourself, or when the job table has become the busiest table in the database.
  2. Events that several internal services react to, where a lost message is quickly replaced by a newer one. Core NATS, with subjects as the routing scheme and queue groups where you need work sharing. Add a JetStream stream for the narrow set of subjects that must survive a restart, and leave the rest in memory.
  3. An event log that consumers read from the beginning, for an audit trail, rebuilding a read model, or feeding analytics later. JetStream with file storage and an explicit byte cap. Choose Kafka only when a downstream tool requires the Kafka protocol, and accept the JVM heap as the price of that compatibility.

The cost of the wrong choice on a single server is not throughput. It is the recovery at three in the morning, when you need to know whether the messages still exist. Pick for that.

FAQ

Can I run Kafka on a 2 GB VPS?

It starts, and it will be tight. bin/kafka-server-start.sh sets KAFKA_HEAP_OPTS="-Xmx1G -Xms1G" when you have not overridden it, so the JVM claims 1 GB before storing any message, and Kafka relies on free memory beyond that for the page cache. Add your application and database on the same box and you are into swap. You also get replication factor 1, meaning acks=all is one fsync on one disk, so you are paying Kafka's operating cost without its durability model. NATS JetStream gives durable replay on the same hardware for much less memory.

Do I need a message queue if I already run Postgres?

Often not. A job table read with SELECT ... FOR UPDATE SKIP LOCKED inside a transaction gives at-least-once delivery, safe concurrent workers, retries and a dead letter table, with no extra service to monitor and backups you already take. The signals to move out are specific: the queue table becomes your heaviest write load and autovacuum falls behind, long-running jobs hold transactions open and block vacuum for the whole database, or a second service needs to consume the same events independently.

Should I use NATS JetStream or RabbitMQ for background jobs?

RabbitMQ if you want per-message acknowledgement, a delivery limit and dead letter routing as built-in behaviour. Quorum queues are always durable, the delivery limit defaults to 20 from RabbitMQ 4.0, and a policy sends exhausted messages to a dead letter exchange you can drain and inspect. JetStream if the same events also need replay by other consumers later, since a stream keeps messages after acknowledgement while a queue does not. With JetStream you set --max-deliver and build the dead letter path yourself from the $JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.> advisory.

How do I tell how far behind my consumers are?

Each broker has one command. For RabbitMQ, rabbitmqctl list_queues name messages messages_ready messages_unacknowledged separates work waiting for a consumer from work delivered and never acked. For JetStream, nats consumer report <stream> shows unprocessed messages and outstanding acknowledgements per consumer. For Kafka, kafka-consumer-groups.sh --bootstrap-server localhost:9092 --describe --group <group> prints a LAG column per partition. Core NATS has no backlog to read, because it stores nothing, so watch the slow_consumers counter on http://localhost:8222/varz instead: it counts connections the server closed for falling behind, which is message loss.

#nats#rabbitmq#kafka#message-queue#architecture