<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jörg Halitschke</title>
    <description>The latest articles on DEV Community by Jörg Halitschke (@fettekette).</description>
    <link>https://dev.to/fettekette</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1377376%2Fd61de0d7-cf04-4228-bc71-363b620e88df.jpeg</url>
      <title>DEV Community: Jörg Halitschke</title>
      <link>https://dev.to/fettekette</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fettekette"/>
    <language>en</language>
    <item>
      <title>rsync -a Stole the setgid Bit, and systemd Blocked the Rollback</title>
      <dc:creator>Jörg Halitschke</dc:creator>
      <pubDate>Sun, 15 Nov 2026 17:43:04 +0000</pubDate>
      <link>https://dev.to/fettekette/rsync-a-stole-the-setgid-bit-and-systemd-blocked-the-rollback-14h2</link>
      <guid>https://dev.to/fettekette/rsync-a-stole-the-setgid-bit-and-systemd-blocked-the-rollback-14h2</guid>
      <description>&lt;p&gt;The setup was deliberately boring. A small Node.js app with SQLite on a single VM, a release-directory layout with a &lt;code&gt;current&lt;/code&gt; symlink, a CI workflow that uploads each release over SSH, runs &lt;code&gt;npm ci&lt;/code&gt;, flips the symlink, restarts the systemd unit and waits for &lt;code&gt;/healthz&lt;/code&gt;. If the health check fails, flip the symlink back and restart again.&lt;/p&gt;

&lt;p&gt;Every piece of that looked right in review. The first time CI ran it end to end, the deploy went red, the app went down, and the rollback — the part that exists precisely for this moment — failed as well.&lt;/p&gt;

&lt;p&gt;Neither failure was in the deploy logic. Both were in what the tools around it did by default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; &lt;code&gt;rsync -a&lt;/code&gt; includes &lt;code&gt;-p&lt;/code&gt; and &lt;code&gt;-g&lt;/code&gt;, so it copies the &lt;em&gt;sender's&lt;/em&gt; mode and group onto the target — including onto the release directory itself, which silently clears the setgid bit the server relied on. &lt;code&gt;npm ci&lt;/code&gt; then wrote &lt;code&gt;node_modules&lt;/code&gt; with the deploy user's private group, and the app, running as a different user that only shares a group, couldn't read its own dependencies. The app crash-looped into systemd's &lt;code&gt;StartLimitBurst&lt;/code&gt;, after which systemd refused &lt;em&gt;every&lt;/em&gt; start — including the rollback's — until &lt;code&gt;systemctl reset-failed&lt;/code&gt;. Fix: normalise group and setgid after the upload, call &lt;code&gt;reset-failed&lt;/code&gt; before every restart, and stop waiting on a health check once the unit has visibly crashed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Two Users in the First Place?
&lt;/h2&gt;

&lt;p&gt;Because the process that writes code to the server shouldn't be the process that runs it.&lt;/p&gt;

&lt;p&gt;The app runs as an unprivileged runtime user with no sudo and no SSH key. Deploys run as a separate deploy user whose key is restricted and whose sudo rights cover exactly two commands: restarting the unit and &lt;code&gt;reset-failed&lt;/code&gt; (why the second one is on the list is most of this post). The two share one group, and the shared directories are set up so both can write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# once, at setup&lt;/span&gt;
chgrp -R app-shared /srv/app
chmod -R g+rwX /srv/app
find /srv/app -type d -exec chmod g+s &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; +   &lt;span class="c"&gt;# new files inherit the group&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The setgid bit on a directory is what makes this work without either user having to think about it: every file created inside inherits the directory's group instead of the creator's primary group. Combined with &lt;code&gt;umask 0007&lt;/code&gt; on both sides, everything the deploy user creates is group-readable and group-writable, and everything outside the group sees nothing.&lt;/p&gt;

&lt;p&gt;That's the property the first deploy lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Did rsync Actually Change?
&lt;/h2&gt;

&lt;p&gt;The workflow creates the release directory first, then uploads into it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh deploy-target &lt;span class="s2"&gt;"mkdir -p '/srv/app/releases/${REL}'"&lt;/span&gt;
rsync -az ./ &lt;span class="s2"&gt;"deploy-target:/srv/app/releases/${REL}/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;mkdir&lt;/code&gt; happens inside &lt;code&gt;releases/&lt;/code&gt;, which has setgid, so the new directory is created correctly: shared group, setgid set. Then rsync runs, and &lt;code&gt;-a&lt;/code&gt; is shorthand for &lt;code&gt;-rlptgoD&lt;/code&gt;. Two of those letters do the damage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-p&lt;/code&gt; copies permissions from the sender.&lt;/strong&gt; On the CI runner, the checkout directory has no setgid bit — why would it. A trailing slash on the source (&lt;code&gt;./&lt;/code&gt;) means rsync syncs the &lt;em&gt;attributes of the directory itself&lt;/em&gt; as well as its contents, so it &lt;code&gt;chmod&lt;/code&gt;s the freshly created release directory to the runner's mode. setgid gone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;-g&lt;/code&gt; copies the group.&lt;/strong&gt; A non-root receiver can only assign groups it is a member of, so rsync falls back to the deploy user's own primary group for everything it creates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nothing about this looks wrong in a listing unless you're looking for it. The files are there, the sizes match, the owner is the deploy user as expected. The difference is one letter in the mode column and one name in the group column.&lt;/p&gt;

&lt;p&gt;The next step made it fatal. &lt;code&gt;npm ci&lt;/code&gt; runs on the server, as the deploy user, inside a release directory that no longer has setgid. Every file in &lt;code&gt;node_modules&lt;/code&gt; got the deploy user's private group, with &lt;code&gt;umask 0007&lt;/code&gt; stripping all "other" bits. The runtime user — different UID, member only of the shared group — couldn't read a single dependency. &lt;code&gt;require('express')&lt;/code&gt; failed on the first line of &lt;code&gt;server.js&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Didn't the Rollback Save It?
&lt;/h2&gt;

&lt;p&gt;Because by the time the script tried to roll back, systemd had stopped taking orders.&lt;/p&gt;

&lt;p&gt;The unit had sensible crash-loop protection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="k"&gt;[Unit]&lt;/span&gt;
&lt;span class="na"&gt;StartLimitIntervalSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;
&lt;span class="na"&gt;StartLimitBurst&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;

&lt;span class="k"&gt;[Service]&lt;/span&gt;
&lt;span class="na"&gt;Restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;failure&lt;/span&gt;
&lt;span class="na"&gt;RestartSec&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crash, wait five seconds, restart, crash again. Five starts inside five minutes and systemd marks the unit &lt;code&gt;failed&lt;/code&gt; with &lt;em&gt;start request repeated too quickly&lt;/em&gt;. That's exactly the right behaviour for a broken binary at 3 a.m. — it stops a crash loop from hammering the machine.&lt;/p&gt;

&lt;p&gt;What's less obvious is that the start limit doesn't distinguish who's asking. Once it trips, a manual &lt;code&gt;systemctl restart&lt;/code&gt; is refused just like the automatic ones. The deploy script flipped &lt;code&gt;current&lt;/code&gt; back to the previous, known-good release and called &lt;code&gt;restart&lt;/code&gt; — and systemd rejected it, because the limit belongs to the unit, not to the code the unit happens to point at.&lt;/p&gt;

&lt;p&gt;So the sequence was: new release can't read its dependencies, unit crash-loops through its burst, health check eventually times out, rollback flips the symlink correctly and then can't start anything. The old release was sitting on disk, intact, one symlink away, and the service stayed down until someone ran &lt;code&gt;reset-failed&lt;/code&gt; by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does the Fix Look Like?
&lt;/h2&gt;

&lt;p&gt;Three changes, each small, each aimed at one of the mechanisms rather than at the symptom.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Normalise the release after upload, before anything runs in it.&lt;/strong&gt; The group to use is read from &lt;code&gt;releases/&lt;/code&gt; itself, so the script doesn't hard-code a group name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;GRP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(stat -c %g "&lt;/span&gt;&lt;span class="nv"&gt;$BASE&lt;/span&gt;/releases&lt;span class="s2"&gt;")"&lt;/span&gt;
find &lt;span class="s2"&gt;"$REL_DIR"&lt;/span&gt; -exec chgrp &lt;span class="s2"&gt;"$GRP"&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; +
find &lt;span class="s2"&gt;"$REL_DIR"&lt;/span&gt; -type d -exec chmod g+rwxs &lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt; +
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs before &lt;code&gt;npm ci&lt;/code&gt;, so &lt;code&gt;node_modules&lt;/code&gt; is created under a setgid directory again and inherits the right group. An alternative is to change the rsync flags — &lt;code&gt;--no-p --no-g --chmod=Dg+s,ug+rw&lt;/code&gt; — and that works, but it puts the correctness of the server layout into a CI file that lives somewhere else. The &lt;code&gt;find&lt;/code&gt; after the fact is idempotent and lives next to the thing it protects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reset the start limit before every restart, not just before rollbacks:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;restart_service&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  sudo -n systemctl reset-failed &lt;span class="s2"&gt;"$SERVICE"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  sudo -n systemctl restart &lt;span class="s2"&gt;"$SERVICE"&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reset-failed&lt;/code&gt; on a healthy unit is a no-op, so there's no reason to make it conditional. It's also why the deploy user's sudo rule has to name it explicitly — a rule that only allows &lt;code&gt;restart&lt;/code&gt; reproduces the original failure with a permission error on top.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop waiting once the unit has visibly crashed.&lt;/strong&gt; The original health check polled &lt;code&gt;/healthz&lt;/code&gt; ten times, three seconds apart, plus curl timeouts — up to about 35 seconds of a dead app behind the reverse proxy returning 502. But there's no point polling an HTTP endpoint on a process systemd has already reported as dead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;healthy&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$(&lt;/span&gt;seq 1 &lt;span class="s2"&gt;"$HEALTH_RETRIES"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    curl -fsS --max-time 5 &lt;span class="s2"&gt;"http://127.0.0.1:${PORT}/healthz"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;return&lt;/span&gt; 0
    &lt;span class="nv"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(systemctl show -p NRestarts --value "&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;" 2&amp;gt;/dev/null || true)"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"$(systemctl is-active "&lt;/span&gt;&lt;span class="nv"&gt;$SERVICE&lt;/span&gt;&lt;span class="s2"&gt;")"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"failed"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"${n:-0}"&lt;/span&gt; -ge 1 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      log &lt;span class="s2"&gt;"service crashed - not waiting for the health check any longer"&lt;/span&gt;
      &lt;span class="nb"&gt;return&lt;/span&gt; 1
    &lt;span class="k"&gt;fi&lt;/span&gt;
    sleep 3
  &lt;span class="k"&gt;done&lt;/span&gt;
  &lt;span class="nb"&gt;return&lt;/span&gt; 1
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A unit that has restarted even once since the deploy's own restart is not going to come good on the next poll. Failing fast here shortens the outage of a bad release from half a minute to a few seconds, and gets to the rollback while the start limit still has budget left.&lt;/p&gt;

&lt;h2&gt;
  
  
  Couldn't This Be Caught Before It Reaches Production at All?
&lt;/h2&gt;

&lt;p&gt;Yes — and that became the next change. The three fixes above make a broken release fail faster and recover reliably. They don't stop a broken release from being switched in. The deploy now boots every release against a copy of the production database on a spare port &lt;em&gt;before&lt;/em&gt; touching &lt;code&gt;current&lt;/code&gt;, and the very first thing that preflight does is look for exactly the failure from this post: files in the release that the shared group can't read.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(find "&lt;/span&gt;&lt;span class="nv"&gt;$REL_DIR&lt;/span&gt;&lt;span class="s2"&gt;" \( -type d ! -perm -g=rx -o -type f ! -perm -g=r \) | head -n 3)"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; -z &lt;span class="s2"&gt;"$bad"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"not group-readable: $bad"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;return&lt;/span&gt; 1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That check exists because the preflight itself runs as the deploy user, not the runtime user — so a permissions problem would sail straight through a "does it boot" test. The rest of that design is &lt;a href="https://deploy-and.run/blog/devops-automation/boot-the-release-against-a-copy-of-production-before-you-switch-158" rel="noopener noreferrer"&gt;the next post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the Generalizable Lesson Here?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;rsync -a&lt;/code&gt; is "archive", and an archive preserves the &lt;em&gt;sender's&lt;/em&gt; metadata. When the receiver's metadata is the part that matters — shared groups, setgid, ACLs — either strip &lt;code&gt;-p&lt;/code&gt;/&lt;code&gt;-g&lt;/code&gt; explicitly or normalise afterwards. A trailing slash on the source applies the source directory's attributes to the target directory too.&lt;/li&gt;
&lt;li&gt;setgid on directories is a quiet invariant: nothing breaks when it's removed, until the next file is created. Anything that relies on it should re-assert it, not assume it.&lt;/li&gt;
&lt;li&gt;With separate deploy and runtime users, "it works for me" during deploy proves nothing about the runtime user. Check readability &lt;em&gt;as the group&lt;/em&gt;, not as yourself.&lt;/li&gt;
&lt;li&gt;systemd's start limit is per unit and blocks manual starts as well as automatic ones. Any script that restarts a unit after a possible crash loop — a rollback above all — needs &lt;code&gt;reset-failed&lt;/code&gt; first, and the sudo rule needs to permit it.&lt;/li&gt;
&lt;li&gt;Don't keep polling an HTTP health endpoint on a process the supervisor has already declared dead. Ask the supervisor first; it knows sooner.&lt;/li&gt;
&lt;li&gt;A rollback that has never run against a real failure is a hypothesis. The interesting state isn't "previous release on disk" — it's "unit just crash-looped through its start limit", and that's the one to rehearse.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>linux</category>
      <category>systemd</category>
      <category>devops</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Boot the Release Against a Copy of Production Before You Switch</title>
      <dc:creator>Jörg Halitschke</dc:creator>
      <pubDate>Sun, 15 Nov 2026 17:43:02 +0000</pubDate>
      <link>https://dev.to/fettekette/boot-the-release-against-a-copy-of-production-before-you-switch-4lpc</link>
      <guid>https://dev.to/fettekette/boot-the-release-against-a-copy-of-production-before-you-switch-4lpc</guid>
      <description>&lt;p&gt;The deploy script already had a rollback. Switch the &lt;code&gt;current&lt;/code&gt; symlink, restart the systemd unit, poll &lt;code&gt;/healthz&lt;/code&gt;, and if that fails, switch back and restart again. On paper that's a safe deploy.&lt;/p&gt;

&lt;p&gt;In practice, every broken release still produced an outage. The reverse proxy returned 502 from the moment the old process stopped until the rollback had brought it back — up to about 35 seconds in the worst case, and a few seconds even after &lt;a href="https://deploy-and.run/blog/server-infrastructure/rsync-a-stole-the-setgid-bit-and-systemd-blocked-the-rollback-159" rel="noopener noreferrer"&gt;the health check learned to fail fast&lt;/a&gt;. A post-switch health check can't prevent that. It runs after the damage, and its only job is to bound it.&lt;/p&gt;

&lt;p&gt;The fix was to move the question "does this release start?" to before the switch, and to ask it against the real data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Before touching &lt;code&gt;current&lt;/code&gt;, the deploy now copies the live SQLite database with &lt;code&gt;VACUUM INTO&lt;/code&gt;, starts the new release on a free localhost port against that copy with a throwaway config and session secret and every outbound job disabled, and waits for &lt;code&gt;/healthz&lt;/code&gt;. If it doesn't come up, the deploy aborts with the test server's last log lines and production never notices. The first check in the preflight is whether the release is readable by the runtime group — because the preflight runs as the deploy user and would otherwise miss permission bugs entirely. A deliberately broken release was then pushed through the real pipeline: the deploy failed, the site served HTTP 200 throughout.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Does a Post-Switch Health Check Actually Protect?
&lt;/h2&gt;

&lt;p&gt;Less than it looks like. The sequence is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;switch &lt;code&gt;current&lt;/code&gt; to the new release&lt;/li&gt;
&lt;li&gt;restart the service — the old process is gone now&lt;/li&gt;
&lt;li&gt;poll &lt;code&gt;/healthz&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;on failure: switch back, restart, poll again&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything between step 2 and the end of step 4 is downtime if the release is broken. The health check decides whether you &lt;em&gt;stay&lt;/em&gt; down, which is valuable, but the outage itself is already happening by the time it reports anything.&lt;/p&gt;

&lt;p&gt;What breaks a release in a small Node.js app is rarely subtle. A missing package after &lt;code&gt;npm ci&lt;/code&gt;, a syntax error in a file the tests didn't load, a schema migration that throws on real data, a file the runtime user can't read. All of those fail within the first second of &lt;code&gt;node server.js&lt;/code&gt;. None of them needs the release to be live to show up. They just need the release to &lt;em&gt;start&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Start a Second Copy Without Touching the First?
&lt;/h2&gt;

&lt;p&gt;Three things have to be separate from production: the port, the data, and anything the app does to the outside world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The port.&lt;/strong&gt; Ask the kernel for one instead of picking a number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(node -e 'const s=require("&lt;/span&gt;net&lt;span class="s2"&gt;").createServer().listen(0,"&lt;/span&gt;127.0.0.1&lt;span class="s2"&gt;",()=&amp;gt;{console.log(s.address().port);s.close()})')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's a tiny race between closing that socket and the app binding it, and on a single-tenant VM it doesn't matter. Binding to &lt;code&gt;127.0.0.1&lt;/code&gt; matters more — the preflight instance must never be reachable from outside, not even for the few seconds it lives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The data.&lt;/strong&gt; The app runs on SQLite in WAL mode, which means copying the &lt;code&gt;.db&lt;/code&gt; file with &lt;code&gt;cp&lt;/code&gt; while the app is running can produce a file that's missing whatever is still sitting in the &lt;code&gt;-wal&lt;/code&gt; file, or one that's torn mid-write. &lt;code&gt;VACUUM INTO&lt;/code&gt; asks SQLite itself for a consistent snapshot, WAL contents included, as a single self-contained file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vacuum_into&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$1"&lt;/span&gt; node -e &lt;span class="s1"&gt;'
    const { DatabaseSync } = require("node:sqlite");
    const db = new DatabaseSync(process.env.DB_FILE);
    db.exec("VACUUM INTO \x27" + process.env.TARGET.replace(/\x27/g, "\x27\x27") + "\x27");
    db.close();
  '&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reads while the live app keeps writing, and it's the same call the deploy already used for its pre-switch backup. Using the real data rather than a fixture is the point: migrations run on startup, and a migration that works on the test schema but fails on a production row is precisely the bug a fixture can't find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The side effects.&lt;/strong&gt; This is the part that's easy to get wrong. A second instance of the app is a second instance of &lt;em&gt;everything&lt;/em&gt; the app does on startup — including its scheduler. This one syncs orders from an external marketplace every couple of hours and sends mail. A preflight instance that starts its scheduler would make real API calls with real credentials, and if it ever pointed at the wrong database, import orders twice. So the preflight instance is made inert:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"$REL_DIR"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exec&lt;/span&gt; env \
    &lt;span class="nv"&gt;PORT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$port"&lt;/span&gt; &lt;span class="nv"&gt;BIND_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;127.0.0.1 \
    &lt;span class="nv"&gt;DB_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$tmp/app.db"&lt;/span&gt; &lt;span class="nv"&gt;CONFIG_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$tmp/config"&lt;/span&gt; \
    &lt;span class="nv"&gt;NODE_ENV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;production &lt;span class="nv"&gt;EXTERNAL_SYNC_DISABLED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt; \
    &lt;span class="nv"&gt;SESSION_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"&lt;/span&gt; \
    node server.js &lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"$tmp/server.log"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1 &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its config directory holds only the checked-in template, not the local file with SMTP and API credentials. Its session secret is random, so a cookie from the live app means nothing to it. And the sync job has an off switch — which is a design requirement worth adding &lt;em&gt;before&lt;/em&gt; you need a preflight, because retrofitting "don't do anything on startup" into an app that assumes it's the only instance is much harder than it sounds.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Has the Preflight Passed?
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;/healthz&lt;/code&gt; answers. When the process dies, it has failed — and there's no reason to keep polling a dead PID:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nv"&gt;$(&lt;/span&gt;seq 1 40&lt;span class="o"&gt;)&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="nb"&gt;kill&lt;/span&gt; -0 &lt;span class="s2"&gt;"$pid"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; curl -fsS --max-time 2 &lt;span class="s2"&gt;"http://127.0.0.1:${port}/healthz"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&lt;span class="o"&gt;&amp;gt;&amp;amp;&lt;/span&gt;1&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="nv"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;break&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
  sleep 0.5
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;span class="nb"&gt;kill&lt;/span&gt; &lt;span class="s2"&gt;"$pid"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="s2"&gt;"$pid"&lt;/span&gt; 2&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On failure the script prints the last fifteen lines of the test server's log into the CI output, trimmed to 300 characters each. That turned out to be the most useful part of the whole change: a failed deploy now says &lt;em&gt;why&lt;/em&gt; in the same place it says &lt;em&gt;that&lt;/em&gt;, instead of sending someone to &lt;code&gt;journalctl&lt;/code&gt; on the server to find a stack trace from a process that's already been replaced.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/healthz&lt;/code&gt; itself is deliberately cheap — it runs &lt;code&gt;SELECT 1&lt;/code&gt; and returns 503 if the database layer throws. By the time it can answer at all, the app has loaded every module, opened the database, and run its startup migrations against the copy. That's the whole set of things this check is for. Whether the business logic is right is the test suite's job, and it ran before the deploy started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Can the Preflight Not See?
&lt;/h2&gt;

&lt;p&gt;Being explicit about this kept the check honest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs as the deploy user, not the runtime user.&lt;/strong&gt; The deploy user owns the release and can read everything in it. The runtime user only shares a group. A file that isn't group-readable boots fine in the preflight and crashes in production — the exact failure from the &lt;a href="https://deploy-and.run/blog/server-infrastructure/rsync-a-stole-the-setgid-bit-and-systemd-blocked-the-rollback-159" rel="noopener noreferrer"&gt;previous post&lt;/a&gt;. So before it starts anything, the preflight checks permissions directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;bad&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"$(find "&lt;/span&gt;&lt;span class="nv"&gt;$REL_DIR&lt;/span&gt;&lt;span class="s2"&gt;" \( -type d ! -perm -g=rx -o -type f ! -perm -g=r \) 2&amp;gt;/dev/null | head -n 3)"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;"$bad"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  log &lt;span class="s2"&gt;"files not readable by the group (the app could not open them):"&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'    %s\n'&lt;/span&gt; &lt;span class="nv"&gt;$bad&lt;/span&gt;
  &lt;span class="nb"&gt;return&lt;/span&gt; 1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It doesn't exercise integrations.&lt;/strong&gt; Outbound sync and mail are off by design. A broken API client starts fine and fails two hours later. That's a trade, not an oversight — the alternative is a preflight with real side effects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It can't run on the first deploy.&lt;/strong&gt; No database yet, nothing to copy. The script logs that it's skipping and carries on; the first deploy initialises the schema instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It doubles memory for a few seconds.&lt;/strong&gt; On a small VM that's worth checking once: on a box already near its memory limit, a preflight could trigger the OOM killer on the live process, which would be a fairly ironic way to cause an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  How Do You Know It Works?
&lt;/h2&gt;

&lt;p&gt;Two ways, and both are needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In the test suite&lt;/strong&gt;, the deploy script runs for real against a temporary directory layout. &lt;code&gt;npm&lt;/code&gt;, &lt;code&gt;sudo&lt;/code&gt; and &lt;code&gt;systemctl&lt;/code&gt; are stubs; &lt;code&gt;node&lt;/code&gt;, &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;bash&lt;/code&gt; are real. One release's &lt;code&gt;server.js&lt;/code&gt; is a one-line HTTP server that answers &lt;code&gt;/healthz&lt;/code&gt;. The other is one line too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'deliberately broken'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests assert that the broken release aborts the deploy, that &lt;code&gt;current&lt;/code&gt; was never switched, that no pre-switch backup was taken, and that the thrown error text appears in the output. A third case drops group-read from &lt;code&gt;server.js&lt;/code&gt; and expects the permission check to name the file; a fourth lets the healthy release through and checks it switches only after preflight. A deploy script with no tests is a deploy script whose failure branches have never run — and the failure branches are the whole reason it exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Against the real server&lt;/strong&gt;, a deliberately broken release went through the real CI pipeline while the site was watched from outside. The deploy went red with the thrown error in its log, and the site answered HTTP 200 the entire time. That's the only result that actually proves the claim, because it's the only one that includes the reverse proxy, systemd, and the real filesystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About the Restart That Still Happens?
&lt;/h2&gt;

&lt;p&gt;A release that passes preflight still needs a restart, and a single-process app on a single VM has a gap of a second or two while that happens. Two small changes make the gap invisible in practice rather than eliminating it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The reverse proxy maps 502/503/504 to a static maintenance page served from outside the release directory, returned as &lt;strong&gt;503 with &lt;code&gt;Retry-After&lt;/code&gt;&lt;/strong&gt; rather than a bare 502. Crawlers and monitors read that as "temporary", humans see a sentence instead of an error page.&lt;/li&gt;
&lt;li&gt;The frontend retries idempotent &lt;code&gt;GET&lt;/code&gt; requests briefly when it sees a 502/503/504, instead of treating any non-2xx as "session expired" and bouncing the user to the login screen — which is what it had been doing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;True zero-downtime would need two processes and a handover, which for an app this size is more machinery than the problem justifies. The preflight moved every &lt;em&gt;predictable&lt;/em&gt; failure out of the restart window. What's left is a planned blip, not an outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the Generalizable Lesson Here?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A health check after the switch measures an outage; it doesn't prevent one. Anything that can fail at startup can be checked before the switch, against a non-serving instance.&lt;/li&gt;
&lt;li&gt;Test against a copy of production data, not a fixture. Startup migrations are the most likely thing to fail on real rows, and the least likely to be covered by test data.&lt;/li&gt;
&lt;li&gt;Copy a live SQLite database with &lt;code&gt;VACUUM INTO&lt;/code&gt;, never &lt;code&gt;cp&lt;/code&gt;. In WAL mode a file copy can be missing committed data or be torn mid-write; &lt;code&gt;VACUUM INTO&lt;/code&gt; gives you one consistent file while the app keeps running.&lt;/li&gt;
&lt;li&gt;A preflight instance must be inert: localhost-only, its own port, its own data, a throwaway secret, no schedulers, no outbound calls, no real credentials. Design the off switches in early — an app that assumes it's the only instance is hard to start twice.&lt;/li&gt;
&lt;li&gt;Write down what the preflight can't see. Here: it runs as the wrong user, so it checks permissions explicitly; it skips integrations, by design.&lt;/li&gt;
&lt;li&gt;Put the failed instance's last log lines into the CI output. A failed deploy that explains itself in the place it fails saves the trip to the server.&lt;/li&gt;
&lt;li&gt;Prove it end to end once with a deliberately broken release and an external probe. The test suite proves the script's logic; only the real run proves the proxy, the supervisor and the filesystem agree with it.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>cicd</category>
      <category>sqlite</category>
      <category>node</category>
    </item>
  </channel>
</rss>
