Build1 distinct publisher3 min readPublished
A dev.to writeup put curl in a loop through its own deploy and got 502 on every request while the app booted, which is the interval the release symlink never covered, and the nginx retry line most teams paste in points at an upstream group of one.
The Engineer · Build desk

Compiled by The EngineerSomething wrong?How this is made
The fix offered in the writeup is to list `127.0.0.1:3000` twice inside one `upstream` block, with `max_fails=0` on both lines, then `proxy_pass http://app` [15]. (The source's own comment on that second line reads: yes, the same address twice.) The duplication is the whole mechanism, because `proxy_next_upstream` only retries the next peer in an upstream group, and a `proxy_pass` aimed at a literal host and port is a group of one [14].
`max_fails=0` is doing real work there too. The nginx default is `max_fails=1 fail_timeout=10s`, and the two entries are one process, so a single refused connect marks both peers down [16]. Without that override the retry path arms itself and then disarms itself on the first failure.
Now the arithmetic the config has to beat. The recorded timeline closes the port at t+0.01 and gets its first 200 at t+2.9 [8], which is 2.89 seconds of refused connects [1]. The quoted retry stanza allows three tries and a two-second connect timeout, and nothing in it delays or spreads those tries across a boot [2]. A refused connect is not a hanging connect, so the tries can be spent long before the process is back. The writeup does not publish a second curl loop after the change [2], so the residual 502 count is unknown. For the twice-listed peer to close a gap this wide, either the retry attempts have to be spread over the boot or the socket has to come back inside the budget. Neither is established by the config as printed.
The PHP-FPM variant is the one worth stealing regardless of the retry question. nginx serves the docroot itself and only `.php` reaches the app socket [11], so during a unit restart the static assets keep returning 200 while every dynamic request gets `ENOENT` from `fastcgi_pass` [12]. That is a deploy fault wearing an application fault's symptoms, and it is caused by a packaging decision most people make for good reasons: put the fpm master in the app's own systemd unit and logs, resource limits and lifecycle become per-app instead of pooled system-wide [13]. The bill for that arrives as an unlinked socket on every restart [12].
The symlink discipline itself is good craft and worth keeping. Two of the three obvious ways to move the pointer are broken, and the working form creates the link beside the target and renames over it, so an in-flight request finishes against the old tree while the next one gets the new one [5][6]. What that buys you is file consistency. It does not buy you a listener, because `restart` is stop then start [7]. Any deploy that ends with a single unit going down and coming up has a hole the width of your boot time, and the honest options run through overlapping processes rather than through retry directives.
Ranked by verification strength, evidence, and original report placement.
The author ran Capistrano-style releases on a plain VM (build into releases/<ref>, swap a symlink, restart the service), put curl in a loop during an actual deploy, and got 502 on every single request for the length of the app's boot.
The release layout is /srv/app/releases/<timestamp-sha> directories with a current symlink pointing at the active release.
ln -sf "$RELEASE" /srv/app/current is wrong because -sf follows an existing symlink-to-a-directory and quietly creates /srv/app/current/<release>.
ln -sfn fixes the follow-into-directory problem but is unlink() then symlink(), leaving a window where the path resolves to nothing.
The correct swap creates the link beside the target (ln -sfn "$RELEASE" /srv/app/current.tmp) then renames over it with mv -Tf, which is an atomic rename(2); mv needs -T for the same reason ln needed -n, otherwise it moves into the existing symlink-to-a-directory.
After a correct swap a request that started on the old release finishes against the old tree, and the next request gets the new one.
Distinct publishers with included, body-backed reporting in this cluster.
dev.to
1 article · August 30, 2026
Follow any of these and your For You feed starts watching them — no settings page required.
build
Build concurrency on one VPS is a division problem, and the app you serve pays the remainder1 distinct publisher
build
The guard that worked in tests and still wrote 2,684 live records1 distinct publisher
build
A 28-host Debian 12 cutover, and the 02:13 failure Ansible could not have prevented1 distinct publisher
build
PHP-FPM's dynamic pool is a one-second idle-worker loop, not a capacity plan1 distinct publisher
Evidence-backed comparisons of source perspectives and observed adoption signals. Read the methodology
Which Builder, Operator, and Investor concerns the observed source mix emphasized—not a truth score.
Evidence, demonstrated adoption, hype gap, incentives, and confidence are assessed independently, each on its own current evidence. How these are measured.
One box, one loop, exact commands
Most of this can be checked without trusting the author at all: ln -sf following a symlink into a directory, mv refusing to without -T, restart meaning stop-then-start, nginx defaulting to max_fails=1 fail_timeout=10s. Those are tool semantics a reader reproduces in a shell in ten minutes. What cannot be reproduced from the page is the number in the headline — the t+0.01 to t+2.9 trace is a single unreplicated run on one VM, published without logs or any note of what the app was doing for 2.8 seconds.
Nothing to count
There is no usage signal of any kind: one person's release directory on one server, no indication how many teams run this deploy shape, no reports of others hitting the gap, no downloads or shipped artifact. The post does not claim otherwise, and we will not manufacture a figure from a single anecdote.
The author undersells his own fix
Rare for a debugging post: the piece deflates itself. Having built up the duplicated-upstream trick, it then says plainly that this buys a retry rather than zero downtime, covers a sub-second gap and not a three-second boot, and sends you to blue-green systemd slots for the real cure. The one place the writing outruns what was shown is calling the duplicate address 'the entire mechanism' with no follow-up curl loop to say which fraction of the 2.89 seconds it actually rescues.
Reputation, not revenue
Nothing is for sale on this page — no product, no sponsor, no benchmark to win. The pressure that exists is the community-platform kind: a config block that 'looks like a typo' travels further than an unglamorous before-and-after measurement, which is a mild pull toward the memorable trick and away from the caveat. To the author's credit, the caveat stayed in.
Strong mechanism, thin measurement
We land mid-scale for a specific reason rather than a hedge: the causal story is solid and self-verifiable, while the evidence behind its headline number is one run nobody else has seen. Corroboration from a second publisher, or simply a second curl loop after the retry change, would move this up quickly; a contradicting trace would barely dent the symlink and restart semantics, which stand on their own.