Skip to main content

Dokploy Killed Its Global Build Queue After Two Years: What Per-Server Build Slots Reveal About Scheduling Outgrowing One Box

10 min readDora NodaDora Noda
Share
On this page

For two years, every build on every server went through one queue. Read that again: if you ran Dokploy against three servers and kicked off a deploy on one of them, a build already running for a completely different app on a completely different machine stood between you and your deploy. In July 2026, Dokploy's v0.29.11 release notes finally said the quiet part out loud: "For the last 2 years, Dokploy has used a single global queue for all builds. Starting with this release, each server now has its own dedicated queue, with support for up to 2 concurrent builds per server — default is 1 (unlimited on Enterprise)."

That one paragraph is worth a whole post, because it compresses a universal scheduling story into a single changelog entry: the cost of a global FIFO queue (head-of-line blocking, worked below with real wait-time math), the exact contract of the replacement (per-server partitions, same-app serialization, configurable slots), and the ceiling the replacement still has (statically partitioned slots strand idle capacity — the precise problem a fleet-level build pool solves). If you self-host Dokploy, Coolify, or anything in the single-box PaaS wave, this is the release that tells you what your build scheduler was actually doing all along.

Two years, one queue: what the release actually shipped

The feature arrived as "Build Concurrency" in the v0.29 line, built on PR #4645 ("concurrent deployments in memory queue"), which added a buildsConcurrency setting per server, a matching UI component, and an in-memory job processor that drains each server's partition independently. The v0.30.0 notes repeat the admission almost verbatim — "for the first two years, Dokploy used a single global build queue across every server" — which tells you the maintainers consider this a load-bearing fix, not a tweak.

The numbers, precisely: each server gets its own queue with a configurable slot count. The default is 1 (behavior closest to the old world, minus the cross-server blocking), the self-hosted cap at launch was 2, and Enterprise was unlimited.

One follow-up matters: PR #4778, "make concurrent builds an OSS feature," later removed the license check and the free-tier clamp, so the queue now uses whatever value you configure (minimum 1). The Enterprise asterisk in the original announcement is therefore history — but the default-1 choice is not an accident, and we'll come back to why it's the smartest part of the design.

One more contract detail from the docs, because it surprises people: multiple builds of the same application or Compose service are always serialized FIFO, even when a server's concurrency is greater than 1. Two builds of the same service share a source directory and a container name; running them side by side would collide. Raising N lets different apps on the same box build simultaneously — it never parallelizes one app against itself.

What a global queue costs: head-of-line blocking, worked

Here is the concrete cost of the old design, with numbers. Imagine a modest Dokploy setup: three servers (A, B, C), and three builds triggered within the same minute — a 12-minute monorepo image build targeting server A, and two 1-minute service builds targeting B and C. Under the old single global FIFO queue, builds drain strictly in arrival order regardless of destination:

BuildTargetDurationStarts (global FIFO)Finishes
Monorepo imageA12 min0:000:12
Service XB1 min0:120:13
Service YC1 min0:130:14

The two 1-minute builds each wait 12+ minutes for a build running on a machine they will never touch. That is head-of-line blocking in its purest form: total makespan 14 minutes for 14 minutes of work, and — the insult — servers B and C sit idle the entire time their builds are queued. Nobody chose this; it was just what "one queue" means when the queue key doesn't include the destination.

Now the same minute under per-server queues. Each server drains its own partition independently, so all three builds start at 0:00 and the makespan collapses to 12 minutes — the two small builds finish at 0:01 instead of 0:13 and 0:14. The delta the release buys this user is not throughput (same three builds, same three machines) but isolation: one server's slow build stops taxing every other server's deploys.

But a single extreme example flatters the fix, so here is the sensitivity the honest reader should demand. Take the same three builds but collapse them onto one shared server, and vary its slot count N:

SetupMakespanSmall builds finish at
Old global queue (N=1 everywhere)14 min0:13, 0:14
Per-server queues, N=1 per server (3 servers)12 min0:01, 0:01
Per-server queues, N=2 on a single shared server12 min0:01, 0:02
Balanced load: three 1-min builds, one per server, old queue3 min0:01, 0:02, 0:03

The last row is the sufficiency case the hype skips: if your builds are short, similarly sized, and evenly spread, the old global queue cost you a minute or two at most, and per-server partitions change almost nothing. The global queue only bites when build durations are skewed (one long build ahead of short ones) or destinations are many.

Single-server users with uniform builds were never the victims here — multi-server users with one heavy repo were. That is exactly the population the v0.29.11 notes are apologizing to.

The new contract, precisely

Stated as a before/after table, so there is no ambiguity about what changed:

BehaviorBefore (global queue)After (per-server queues)
Queue keyNone — one FIFO for all serversPartitioned by server
Cross-server blockingYes: any running build blocks all servers' buildsNo: servers drain independently
Same-server parallelismNone (strictly serial)Up to N slots, configurable per server (default 1)
Same-app parallelismNoneStill none — same app/service always serialized FIFO
Slot ceiling1, hardcodedN (min 1); launch cap 2 self-hosted, unlimited Enterprise, later un-gated to OSS

Two things in that table deserve emphasis. First, default N=1 means a single-server user who never touches the setting gets identical scheduling to before — the upgrade is behavior-preserving for the common case, and the new parallelism is opt-in per box. Second, the same-app FIFO rule is doing quiet correctness work: without it, the first user to set N=2 would corrupt a build the first time two deploys of one app overlapped. The Dokploy team clearly learned where the bodies were buried before digging.

Where per-box caps stop helping: the stranded-capacity boundary

Per-server queues fix blocking. They do not fix partitioning — and partitioning is where the next ceiling lives. Slots are statically assigned to boxes: server A has its N slots, server B has its N, and neither can borrow the other's. So consider the mirror image of the earlier example: server A is idle (nothing queued, N slots free) while server B has four builds queued behind its N=1 slot. Total fleet demand is four builds; total fleet capacity is two idle slots plus one busy one. A scheduler that could see the whole fleet would move work to the idle box. Dokploy's cannot — the queue key is the destination server, and the destination is fixed before the build starts.

The idle slots on A are stranded: capacity that exists, is paid for, and is unreachable exactly when it is needed.

Contrast that with the balanced case where static partitioning is perfectly fine: every server has roughly equal queue depth, builds land where they run, no box idles while another starves. Nothing is stranded, no fleet view would help, and per-box caps are the whole answer. The boundary is load skew: per-server slots are sufficient when load is balanced across boxes and durations are uniform; they strand capacity in direct proportion to how skewed either gets. A team running one heavy monorepo plus a fleet of small services — the modal shape of a growing self-hosted setup — lives permanently on the wrong side of that boundary.

This feature class is also, empirically, easy to get subtly wrong: Coolify has shipped per-server concurrent-build caps for years, and its tracker carries a long trail of "limit ignored" and "wrong server's limit checked" reports. Static per-box accounting sounds simple until builds, build servers, and deploy targets are three different things.

What a fleet pool does instead (and what it owes default-1)

The fix for stranded capacity is to stop assigning build slots to boxes at all. In a fleet-level design, a build is not "slot 1 on server B" — it is a schedulable unit of work placed wherever capacity currently exists.

Kubernetes-native build systems are the cleanest worked example of the pattern: Tekton turns each build into PipelineRun pods the scheduler bin-packs across nodes, Shipwright wraps the same idea in Build/BuildRun CRDs with selectable strategies, and docker buildx create --driver kubernetes goes further down the stack by running BuildKit workers as pods that the cluster autoscales and places. In all three, adding a node grows one shared pool — no per-machine slot tuning, no stranded capacity when one box idles, and priority plus preemption fall out of the scheduler rather than being hand-rolled per queue.

But — and this is the part most fleet pitches skip — Dokploy's default-1 cap has a property a naive shared pool does not: isolation by construction. With N=1, a pathological build (a Dockerfile that eats 32 GB of RAM, a dependency step that wedges a CPU for an hour) can only hurt its own box; it physically cannot starve a neighbor's builds because there is no shared resource to starve over. Move those same builds into one bin-packed pool with no guardrails and the noisy build degrades everyone's builds at once — the fleet version of the exact head-of-line problem v0.29.11 just fixed, now with shared fate instead of strict ordering.

So the fleet answer is not "delete the caps," it is "re-add isolation at the pool level": per-build CPU/memory requests and limits, per-tenant ResourceQuotas, LimitRanges that stop one BuildKit pod from eating the node, and PriorityClasses with preemption so a wedged low-priority build gets evicted instead of squatting. Kubernetes ships every one of those primitives; the point is that they are required, not optional, before a shared build pool is strictly better than default-1-per-box. Dokploy chose the safe default (isolation first, parallelism opt-in); a fleet scheduler must make the same choice deliberately, because its default is the reverse.


Dokploy's two-year global queue is the kind of story that reads as an embarrassment and is actually a roadmap: one queue (simple, blocking) → per-server queues (isolated, statically partitioned) → fleet pool (shared, needs explicit isolation). Each step fixes the previous step's failure mode and introduces the next one's precondition.

The teams running single-box PaaSes should take the win — cross-server head-of-line blocking is gone — and then look honestly at their load skew, because that skew is now the scheduler. And the platforms already operating fleets should notice which property survived every transition: isolation. It was the thing the global queue lacked, the thing default-1 restored, and the thing a shared pool has to rebuild on purpose.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex