Tutorial 2 — Provisioning a vhost from scratch

20 minutes. A vhost, a service account with permissions scoped to its own queues, and a policy — as code you can review and run twice.

Step 1 — The vhost

try (RabbitAdmin admin = RabbitAdmin.connect("http://localhost:15672", "guest", "guest")) {
    admin.createVhost("billing");

    admin.vhosts().forEach(v -> System.out.println(v.name()));
}
/
billing

Run it twice. It succeeds both times — createVhost on an existing vhost is a PUT, and RabbitMQ treats that as "make it so", not "create it now". That property is what makes provisioning code safe to re-run, and it is worth checking for every call you build on.

Step 2 — A service account

admin.createUser("billing-service", "a-real-password", "monitoring");

admin.users().forEach(u -> System.out.println(u.name() + " " + u.tags()));

The tag decides what the management API allows, and nothing about AMQP. A service that only publishes and consumes needs no tag at all. monitoring is here because this one is going to read its own queue depth in tutorial 3.

Step 3 — Permissions, scoped

Now the part that is usually done wrong. The management UI's default button grants .* on all three, which is every resource in the vhost.

RabbitAdmin billing = admin.forVhost("billing");

billing.grant("billing-service",
        "^billing\\..*",     // configure: may declare these
        "^billing\\..*",     // write:     may publish to these
        "^billing\\..*");    // read:      may consume from these

The order is configure, write, read — RabbitMQ's own order. They are regular expressions matched against resource names, not against actions, and an empty string means "nothing", not "everything".

Note there is no vhost argument: the permission is granted in the vhost the client is pointed at. Calling grant on admin here — still pointed at / — would permission the default vhost instead, and succeed while doing it.

Check what you actually created:

admin.permissions().stream()
        .filter(p -> p.user().equals("billing-service"))
        .forEach(p -> System.out.println(
                p.vhost() + "  configure=" + p.configure()
                + "  write=" + p.write() + "  read=" + p.read()));

And find the ones nobody meant to leave open:

admin.permissions().stream()
        .filter(PermissionInfo::isUnrestricted)
        .forEach(p -> System.out.println("UNRESTRICTED: " + p.user() + " on " + p.vhost()));
UNRESTRICTED: guest on /

That is the one an audit will find. On a real broker, guest should not exist.

Step 4 — A policy

Reusing the billing view from the last step — forVhost returns a new client over the same connection and does not modify the original.

billing.putPolicy("billing-ttl", "^billing\\.",
        Map.of("message-ttl", 86_400_000),   // 24 hours
        10);

billing.policies().forEach(p ->
        System.out.println(p.name() + "  " + p.pattern() + "  " + p.definition()));

Step 5 — Why the policy might do nothing

Declare a queue that already carries a TTL as an argument:

docker exec rabbit rabbitmqadmin --vhost billing declare queue \
  --name billing.invoices --durable true \
  --arguments '{"x-message-ttl":60000}'

The policy matches ^billing\., so it applies. The queue has a 24-hour TTL from the policy and a 60-second TTL from its declare.

The queue argument wins. Messages expire after 60 seconds.

QueueInfo q = billing.queue("billing.invoices").orElseThrow();
System.out.println(q.argument("x-message-ttl"));      // 60000, not 86400000

Note the two spellings: x-message-ttl as a queue argument, message-ttl in a policy definition. Same setting, different names, and where both exist the argument silently overrides the policy. This is the single most common reason a policy "does not work" — it is working, and something more specific is beating it.

Only one policy applies to a queue: the matching one with the highest priority. They do not merge.

Step 6 — Make it re-runnable

A provisioning run that fails halfway leaves half of itself behind. There is no transaction. So compute the difference, print it, and only then apply:

List<String> wanted = List.of("billing", "shipping", "analytics");

Set<String> existing = admin.vhosts().stream()
        .map(VhostInfo::name)
        .collect(Collectors.toSet());

List<String> toCreate = wanted.stream()
        .filter(name -> !existing.contains(name))
        .collect(Collectors.toList());

if (toCreate.isEmpty()) {
    System.out.println("nothing to do");
} else {
    System.out.println("will create: " + toCreate);
    toCreate.forEach(admin::createVhost);
}

Run it twice: the second run prints nothing to do. That is the same compute-print-apply shape Topology uses in AceMQ for Java, and it is what turns a provisioning script into something you are willing to run against production.

Step 7 — Clean up

admin.deleteVhost("billing");
admin.deleteUser("billing-service");

deleteVhost destroys every queue, exchange, binding, policy and message in it, with no confirmation and no undo. It is exactly as fast on a busy production vhost as on this one.

What you have

Next: queue depth without a consumer.