Deploy RabbitMQ and Wire Up Producers and Consumers
RabbitMQ is a message broker that sits between the services that create work (producers) and the services that process it (consumers). Producers publish messages to the broker and move on. Consumers pull messages at their own pace and acknowledge each one after processing; a failed message can be retried or dead-lettered instead of lost. This decouples request handling from slow work like sending email, processing uploads, or calling external APIs. This guide deploys RabbitMQ on Railway and connects a producer and a consumer to it over private networking.
On Railway, a typical setup is three services in one project:
- RabbitMQ: the broker, deployed from a template or Docker image, with a volume for message persistence.
- Producer: your API service. It accepts requests and publishes messages.
- Consumer: a worker service with no public domain. It consumes messages and does the work.
The services talk to each other over private networking, so broker traffic never leaves your project and generates no egress charges.
Deploy RabbitMQ
Option 1: use a template
Search the template marketplace for RabbitMQ and click Deploy Now. Templates come preconfigured with credentials and a volume. You can also add a template to an existing project: click + New on your project canvas and select Template. See deploy a template for the full flow.
Option 2: deploy the Docker image
If you prefer to configure it yourself:
- In your project, click + New, choose Docker Image, and enter
rabbitmq:4-management. The-managementtag includes the web management UI. - In the service's Variables tab, set the default credentials:
RABBITMQ_DEFAULT_USER=admin
RABBITMQ_DEFAULT_PASS=<generate a strong password>- Attach a volume so messages survive restarts. Create a volume from the command palette (
⌘K) or by right-clicking the project canvas, connect it to the RabbitMQ service, and set the mount path to/var/lib/rabbitmq. Without a volume, queues and messages are lost whenever the container restarts. Volumes cost $0.15 per GB per month and are only mounted at runtime, not during builds. - Deploy. RabbitMQ listens for AMQP connections on port
5672and serves the management UI on port15672.
Expose the management UI
The management UI is a regular HTTP app, so you can put it behind a Railway domain:
- Open the RabbitMQ service's Settings tab.
- In the Networking section, generate a domain and set the target port to
15672. - Open the generated
*.up.railway.appURL and log in withRABBITMQ_DEFAULT_USERandRABBITMQ_DEFAULT_PASS.
From the UI you can watch queue depth, message rates, consumer counts, and unacknowledged messages. Anyone with the URL can reach the login page, so use a strong password.
Connect over private networking
Every service in an environment gets an internal DNS name at <service-name>.railway.internal. If your broker service is named rabbitmq, producers and consumers in the same environment reach it at rabbitmq.railway.internal:5672. That reachability stops at the environment boundary, so your staging services cannot reach your production broker.
Instead of hardcoding credentials in each service, define one RABBITMQ_URL variable on the producer and consumer using reference variables:
RABBITMQ_URL=amqp://${{rabbitmq.RABBITMQ_DEFAULT_USER}}:${{rabbitmq.RABBITMQ_DEFAULT_PASS}}@${{rabbitmq.RAILWAY_PRIVATE_DOMAIN}}:5672Railway resolves the references at deploy time. Rotate the password on the broker and both services pick it up on their next deploy.
Private networking has two limits:
- It is available at runtime only. Do not try to connect to RabbitMQ from a build step.
- Use plain
amqp://, notamqps://. Traffic between services is already encrypted with WireGuard.
If an external system outside Railway must publish or consume directly, enable TCP Proxy on the RabbitMQ service with internal port 5672. Railway generates a public domain:port pair that proxies raw TCP to the broker. Keep this off unless you need it; internal services should use the private address.
Write the producer
The producer is an HTTP API that publishes a message for each incoming request. We use Express and amqplib.
Install the dependencies:
npm install express amqplib// producer.js
const express = require("express");
const amqp = require("amqplib");
const QUEUE = "tasks";
let channel = null;
async function connect() {
const connection = await amqp.connect(process.env.RABBITMQ_URL);
connection.on("close", () => {
console.error("RabbitMQ connection closed, exiting");
process.exit(1); // Railway restarts the service
});
channel = await connection.createChannel();
await channel.assertQueue(QUEUE, { durable: true });
}
const app = express();
app.use(express.json());
app.post("/jobs", (req, res) => {
if (!channel) {
return res.status(503).json({ error: "broker not connected" });
}
const payload = Buffer.from(JSON.stringify(req.body));
channel.sendToQueue(QUEUE, payload, { persistent: true });
res.status(202).json({ queued: true });
});
app.get("/health", (req, res) => {
if (!channel) {
return res.status(503).json({ ok: false });
}
res.json({ ok: true });
});
const port = process.env.PORT || 3000;
connect()
.then(() => {
app.listen(port, () => console.log(`Producer listening on ${port}`));
})
.catch((err) => {
console.error("Failed to connect to RabbitMQ:", err.message);
process.exit(1);
});Two details matter for durability. The queue is declared with durable: true, so the queue definition survives a broker restart. Each message is published with persistent: true, so the message itself is written to disk on the volume. You need both, plus the volume on the broker, for messages to survive a restart.
The /health endpoint returns 503 until the broker connection is up. Set it as the service's healthcheck path in the deploy settings so a deploy is only marked live once the producer can publish. Healthchecks also guarantee zero-downtime deploys: the old version keeps serving until the new one passes.
Write the consumer
The consumer is a plain worker with no HTTP server and no public domain. Deploy it as its own service in the same project.
npm install amqplib// consumer.js
const amqp = require("amqplib");
const QUEUE = "tasks";
async function handleJob(job) {
// Replace with real work: send an email, resize an image, call an API.
console.log("Processing job:", job);
}
async function main() {
const connection = await amqp.connect(process.env.RABBITMQ_URL);
connection.on("close", () => {
console.error("RabbitMQ connection closed, exiting");
process.exit(1); // Railway restarts the service
});
const channel = await connection.createChannel();
await channel.assertQueue(QUEUE, { durable: true });
channel.prefetch(5);
await channel.consume(QUEUE, async (msg) => {
if (!msg) return;
try {
const job = JSON.parse(msg.content.toString());
await handleJob(job);
channel.ack(msg);
} catch (err) {
console.error("Job failed:", err.message);
// Requeue once; drop on redelivery to avoid poison-message loops.
channel.nack(msg, false, !msg.fields.redelivered);
}
});
console.log(`Consuming from "${QUEUE}"`);
}
main().catch((err) => {
console.error("Failed to start consumer:", err.message);
process.exit(1);
});Key decisions in this code:
- Manual acknowledgment. The consumer calls
channel.ack(msg)only after the job succeeds. If the consumer crashes mid-job, RabbitMQ redelivers the message to another consumer. - Prefetch.
channel.prefetch(5)caps unacknowledged messages per consumer at 5. Without it, RabbitMQ pushes the entire queue to the first consumer that connects. - Poison messages. On failure, the message is requeued once (
!msg.fields.redelivered). If it fails again it is dropped. For production, configure a dead-letter exchange instead of dropping. - Exit on disconnect. When the broker connection closes, the process exits with a non-zero code and Railway restarts the service, which reconnects cleanly.
To process more messages in parallel, scale the consumer service to multiple replicas or increase the prefetch count. Those extra replicas help because RabbitMQ distributes messages across all consumers on the same queue.
Do not enable serverless (app sleeping) on the consumer or the broker. An idle consumer still needs its open AMQP connection, and the heartbeat traffic on that connection counts as outbound activity, so the service would never sleep anyway.
Test the pipeline
Publish a job through the producer's public domain:
curl -X POST https://your-producer.up.railway.app/jobs \
-H "Content-Type: application/json" \
-d '{"type": "welcome-email", "userId": 42}'Then check two places:
- The consumer service's logs should show
Processing job: { type: 'welcome-email', userId: 42 }. - The management UI's Queues tab should show the
tasksqueue with a brief spike in message rate and zero messages ready.
If messages pile up as "Ready" with no consumers listed, the consumer is not connected: check its logs for a bad RABBITMQ_URL or a service name mismatch in the reference variable.
Next steps
- Choose between cron jobs, background workers, and queues for when RabbitMQ is the right tool.
- Private networking for how internal DNS and environment isolation work.
- Variables for reference variables and shared variables across services.
- Scaling your application for adding replicas to consumers.