<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Linux | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/linux/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/linux/</link>
	<description>Cloud, DevOps, Data &#38; AI — Built, Tested, Explained</description>
	<lastBuildDate>Sat, 24 Oct 2026 12:36:58 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=7.1.4</generator>

<image>
	<url>https://john-nessime.com/blog/wp-content/uploads/2026/07/cropped-jn-32x32.png</url>
	<title>Linux | John Nessime</title>
	<link>https://john-nessime.com/blog/linux/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</title>
		<link>https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 24 Oct 2026 12:36:55 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[WordPress]]></category>
		<category><![CDATA[Cloudflare]]></category>
		<category><![CDATA[CloudLinux]]></category>
		<category><![CDATA[cPanel]]></category>
		<category><![CDATA[CustomBuild]]></category>
		<category><![CDATA[CVE Management]]></category>
		<category><![CDATA[CVE-2026-109618]]></category>
		<category><![CDATA[CVE-2026-109619]]></category>
		<category><![CDATA[DirectAdmin]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Incident Response]]></category>
		<category><![CDATA[mod_php]]></category>
		<category><![CDATA[Patch Management]]></category>
		<category><![CDATA[PHP]]></category>
		<category><![CDATA[PHP-FPM]]></category>
		<category><![CDATA[PHP2Shell]]></category>
		<category><![CDATA[Remote Code Execution]]></category>
		<category><![CDATA[Secrets Management]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Vulnerability Management]]></category>
		<category><![CDATA[Web Application Firewall]]></category>
		<category><![CDATA[WordPress Security]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=1217</guid>

					<description><![CDATA[<p>PHP2Shell is a pair of critical flaws inside PHP itself, so updating WordPress or your framework fixes nothing. Here is what broke, who is exposed, how to patch every common setup, and how to prove the fix is actually running.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/">PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">On the evening of 20 October 2026, php.net posted something it almost never posts: an advance warning that an emergency security release was coming the next day, for two critical bugs it would not yet describe. By the next morning the details had leaked, a proof-of-concept video was circulating, and the same message was landing in every hosting inbox: &#8220;We updated WordPress last week. We&#8217;re fine, right?&#8221;</p>



<p class="wp-block-paragraph">No. And that question is the entire problem with this one.</p>



<p class="wp-block-paragraph">This post is a reference for the <strong>PHP2Shell vulnerability</strong>: what actually broke inside PHP, which servers are exposed, how to patch each common setup, how to prove the fix is really running, and which quick fixes give you false comfort. Keep it open while you work through your servers.</p>



<h2 class="wp-block-heading">What happened: the PHP2Shell vulnerability in plain terms</h2>



<p class="wp-block-paragraph">PHP2Shell is the nickname researchers gave to two flaws in the PHP engine itself, <strong>CVE-2026-109618</strong> and <strong>CVE-2026-109619</strong>. PHP rates both 9.2 on CVSS v4.0, and both can let an unauthenticated attacker run code on a web server. The name is the researchers&#8217;, not the PHP project&#8217;s.</p>



<p class="wp-block-paragraph">The dates matter here, because they decide if you were exposed and for how long:</p>



<ul class="wp-block-list">
<li><strong>20 October 2026:</strong> PHP announced an emergency release for two critical issues. Details leaked publicly the same day.</li>



<li><strong>21 October 2026:</strong> fixed releases shipped: <strong>PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35</strong>, with a full advisory.</li>



<li><strong>23 October 2026:</strong> PHP published an informational advisory for a third, much narrower issue, CVE-2026-115560, with a one-line mitigation and no emergency release.</li>
</ul>



<p class="wp-block-paragraph">The flaws were reported by Wiz Research, Calif and OpenAI&#8217;s Codex Security team, who used AI models to turn long-standing weaknesses into working exploits. The affected code goes back to PHP 5.4, so effectively every PHP version from the last decade is in scope.</p>



<h3 class="wp-block-heading">The two bugs, without the jargon</h3>



<p class="wp-block-paragraph"><strong>CVE-2026-109618 (out-of-memory corruption).</strong> PHP enforces <code>memory_limit</code> by aborting a request the instant it crosses the limit. If that abort lands while PHP is still building internal request data, for example while parsing a multipart file upload, the data is left half-initialized. Request cleanup then treats it as valid and works with memory that can still hold attacker-controlled leftovers.</p>



<p class="wp-block-paragraph"><strong>CVE-2026-109619 (stale global state).</strong> Some state owned by one request can survive after its memory has been freed, for example after a fatal error. The next request handled by the same worker can use that stale state before normal setup resets it.</p>



<p class="wp-block-paragraph">Here is why this is worse than the usual plugin CVE. The bug lives in the runtime, not in your code. A site whose own code is flawless is still exposed, and updating WordPress, Laravel or Drupal changes nothing, because the PHP binary underneath comes from your OS packages, your host or your container image. The researchers also demonstrated reading memory from the PHP worker process, which can contain passwords, tokens and keys belonging to other people&#8217;s requests, without ever getting a shell.</p>



<h2 class="wp-block-heading">Who is exposed, and why mod_php is the worst case</h2>



<p class="wp-block-paragraph">PHP&#8217;s advisory covers every installation that serves web requests: PHP-FPM, Apache with mod_php, php-cgi, FrankenPHP and LiteSpeed. The risk is not equal across them, though.</p>



<p class="wp-block-paragraph"><strong>mod_php is the highest risk.</strong> Apache with mod_php runs request shutdown even when request startup already failed. That means simply running out of memory while PHP parses the request body is enough, and one unauthenticated request can trigger the attack before a single line of your application runs. PHP demonstrated this with default memory and input limits.</p>



<p class="wp-block-paragraph"><strong>PHP-FPM is harder to hit, not safe.</strong> FPM shuts the worker down when a request fails early in setup, which blocks the cleanest path. Public reporting says one of the two bugs was still shown to reach code execution under FPM, through a path that depends on something the application does. Treat &#8220;we run FPM&#8221; as &#8220;we have slightly more time,&#8221; nothing more.</p>



<p class="wp-block-paragraph">If you are not sure which mode you run, these two checks answer it on most Linux servers. The first lists Apache modules and shows a PHP module only if mod_php is loaded. The second lists running FPM services.</p>



<pre class="wp-block-code"><code>apachectl -M | grep -i php
systemctl list-units --type=service | grep -i fpm</code></pre>



<h2 class="wp-block-heading">How to fix PHP2Shell: it depends on where your PHP comes from</h2>



<p class="wp-block-paragraph">The fix is always the same in principle: run a patched PHP build. Getting there depends entirely on who builds your PHP. And there is a trap that runs in both directions. Linux distributions and long-term-support vendors received the patch early and backported it, so a <strong>patched</strong> package can carry an old-looking version like 8.3.6. Meanwhile, an <strong>unpatched</strong> process can keep running after the package updated. The version string answers neither question reliably.</p>



<h3 class="wp-block-heading">Debian and Ubuntu packages</h3>



<p class="wp-block-paragraph">Debian, Ubuntu and Ondřej Surý&#8217;s widely used repository (packages.sury.org, also published as ppa:ondrej/php) all shipped fixed packages, including builds for some end-of-life branches. Update normally, then confirm the CVE is in the package changelog instead of reading the version number. Replace 8.3 with your branch.</p>



<pre class="wp-block-code"><code>sudo apt update &amp;&amp; sudo apt upgrade
zgrep CVE-2026-109618 /usr/share/doc/php8.3-common/changelog.Debian.gz
sudo systemctl restart php8.3-fpm
sudo systemctl restart apache2</code></pre>



<p class="wp-block-paragraph">If <code>zgrep</code> prints a line, the fix is in the installed package. If it prints nothing, you are either on an older distro release that needs a paid extended-support channel, or on a repository that has not shipped the fix.</p>



<h3 class="wp-block-heading">RHEL, AlmaLinux and Rocky Linux</h3>



<p class="wp-block-paragraph">Same idea with RPM. Update, then search the package changelog for the CVE. If you use Remi&#8217;s parallel packages, the package name follows its own pattern (for example <code>php83-php-common</code>), so query that name instead.</p>



<pre class="wp-block-code"><code>sudo dnf upgrade 'php*'
rpm -q --changelog php-common | grep CVE-2026-109618
sudo systemctl restart php-fpm httpd</code></pre>



<p class="wp-block-paragraph">One warning from PHP&#8217;s own advisory: the volunteer-run rpms.remirepo.net repository was not expected to have updates on release day, and PHP suggested switching to distribution packages or building from source if you depend on it. Check before assuming the update landed.</p>



<h3 class="wp-block-heading">cPanel, CloudLinux and LiteSpeed</h3>



<p class="wp-block-paragraph">cPanel ships its own <code>ea-php</code> packages through EasyApache 4, and CloudLinux ships <code>alt-php</code>. CloudLinux backported the fix into supported and end-of-life branches, so most of its fixed builds keep version numbers below the php.net release. CloudLinux&#8217;s published procedure is to update both package families, then restart the services that run PHP so no worker keeps the old binary loaded.</p>



<pre class="wp-block-code"><code>yum clean all &amp;&amp; yum update 'alt-php*' 'ea-php*' -y
/scripts/restartsrv_httpd</code></pre>



<p class="wp-block-paragraph">If your domains run PHP-FPM through cPanel, restart the FPM service as well. On LiteSpeed, restart the web server so every <code>lsphp</code> process respawns from the new build. Public analysis says LiteSpeed&#8217;s LSAPI behaves more like FPM than mod_php, which buys time but does not remove the need to patch.</p>



<h3 class="wp-block-heading">DirectAdmin with CustomBuild</h3>



<p class="wp-block-paragraph">CustomBuild compiles PHP from source, so you get the fix when CustomBuild offers the patched release and you rebuild. Refresh the version list, confirm the fixed release appears for each PHP version you run, then build.</p>



<pre class="wp-block-code"><code>cd /usr/local/directadmin/custombuild
./build update
./build versions
./build update_versions</code></pre>



<p class="wp-block-paragraph">Read the <code>./build versions</code> output before running the last command. If a branch you use still shows an unpatched release, apply the mitigations below for that branch until it updates. Compiling takes a while on small servers, so plan for it rather than starting it at peak traffic.</p>



<h3 class="wp-block-heading">Docker and container images</h3>



<p class="wp-block-paragraph">A container never patches itself. If your image is built <code>FROM</code> an official <code>php</code> tag, rebuild with a fresh pull of the base image and redeploy. If your Dockerfile pins the base by digest, the digest has to change or nothing happens.</p>



<pre class="wp-block-code"><code>docker compose build --pull
docker compose up -d</code></pre>



<p class="wp-block-paragraph">Then run <code>php -v</code> inside the running container, not on the host, to confirm the version the app actually uses.</p>



<h3 class="wp-block-heading">Managed hosting versus unmanaged VPS</h3>



<p class="wp-block-paragraph">On managed hosting, the host owns the PHP runtime, and several large hosts announced they had patched their fleets. Your job there is to get written confirmation, not to assume. On an unmanaged VPS from Contabo, Interserver or any similar provider, nobody patches PHP but you. That is the trade you accepted for the lower price and full root access, and this week is when it gets collected.</p>



<h3 class="wp-block-heading">End-of-life PHP: 8.1 and older</h3>



<p class="wp-block-paragraph">The PHP project released no fixed builds below 8.2. Because of the severity, its security team published minimal source backports for PHP 7.4, 8.0 and 8.1 for people who build from source. Beyond that, your options are a distribution or vendor that backports (Ubuntu&#8217;s own PHP 8.1 on 22.04, the Surý repository, Freexian ELTS for older Debian, CloudLinux and TuxCare, HeroDevs), or upgrading. Note that PHP 8.2 itself reaches end of life on 31 December 2026, so if you are touching an 8.2 server now, plan the next upgrade at the same time.</p>



<h2 class="wp-block-heading">Prove the patch is actually running</h2>



<p class="wp-block-paragraph">This is the failure mode that will bite most people. The package manager says &#8220;upgraded,&#8221; the ticket gets closed, and the old PHP code keeps serving traffic because the processes that loaded it were never restarted. Linux happily keeps running a binary whose file has been replaced on disk.</p>



<ol class="wp-block-list">
<li><strong>Confirm the fix is in the package</strong> with the changelog check above, not the version string.</li>



<li><strong>Restart everything that runs PHP:</strong> every FPM pool, Apache (for mod_php), LiteSpeed, and any long-running PHP workers such as queue consumers managed by Supervisor or systemd.</li>



<li><strong>Check the restart time is after the upgrade.</strong> <code>systemctl status php8.3-fpm</code> shows when the service started. If that is earlier than your package install, it is still running old code.</li>



<li><strong>Ask the system what still needs a restart.</strong> On Debian and Ubuntu, <code>sudo needrestart -r l</code> lists services still using outdated files, without restarting anything.</li>



<li><strong>Check the right binary.</strong> <code>php -v</code> on the command line reports the CLI build, which can differ from the FPM build serving your site. On Debian and Ubuntu, <code>php-fpm8.3 -v</code> asks the FPM binary directly.</li>
</ol>



<h2 class="wp-block-heading">Mitigations if you cannot patch today, and what they don&#8217;t do</h2>



<p class="wp-block-paragraph">PHP is blunt about this: only an update is a complete fix. The settings below reduce exposure to the specific attack paths it has confirmed. They do not make a server safe, and some of them have real costs. Use them to buy hours, not weeks.</p>



<p class="wp-block-paragraph">Set them where applications cannot override them: <code>php_admin_value</code> or <code>php_admin_flag</code> in the FPM pool, or the Apache configuration for mod_php. Do not rely on <code>ini_set()</code> or an FPM <code>.user.ini</code> file, because those apply too late in the request. Here is the FPM pool version on a Debian-style path (adjust the file and branch to yours):</p>



<pre class="wp-block-code"><code>; /etc/php/8.3/fpm/pool.d/www.conf
php_admin_value[memory_limit] = -1
php_admin_value[post_max_size] = 8M
php_admin_value[upload_max_filesize] = 6M
php_admin_flag[session.upload_progress.enabled] = off</code></pre>



<p class="wp-block-paragraph">And the Apache equivalent for mod_php, inside the virtual host or a <code>Directory</code> block:</p>



<pre class="wp-block-code"><code>php_admin_value memory_limit -1
php_admin_value post_max_size 8M
php_admin_value upload_max_filesize 6M
php_admin_flag session.upload_progress.enabled off</code></pre>



<p class="wp-block-paragraph">Restart every worker afterwards. Now the reasoning behind each line, because each one protects a different path:</p>



<ul class="wp-block-list">
<li><strong><code>memory_limit = -1</code></strong> targets CVE-2026-109618. The confirmed exploit needs a predictable per-request limit to hit, so removing the limit removes the trigger. The cost: one request can now consume all available memory. A genuine allocation failure can also still reach the vulnerable code.</li>



<li><strong>Low <code>post_max_size</code></strong> shrinks the request bodies an attacker can use. PHP suggests 8M where uploads are not needed and 32M where they are, with <code>upload_max_filesize</code> kept below it.</li>



<li><strong><code>session.upload_progress.enabled = off</code></strong> targets CVE-2026-109619. It blocks the confirmed session paths but not other variants. It does nothing at all for CVE-2026-109618.</li>



<li><strong>On PHP 8.5 only, <code>fatal_error_backtraces = off</code></strong> blocks the confirmed fatal-backtrace path for CVE-2026-109619, and nothing else.</li>
</ul>



<p class="wp-block-paragraph">Because removing the memory limit opens a denial-of-service risk, PHP recommends a container or cgroup memory limit as the availability boundary, ideally one that kills only the affected worker. It specifically warns against <code>RLIMIT_AS</code> or <code>RLIMIT_DATA</code>, since those make allocations fail at the limit, which is exactly the condition the bug needs. Enforcing request-size, field-count and multipart-part limits at your reverse proxy helps too.</p>



<p class="wp-block-paragraph">A web application firewall is a reasonable stopgap, not a fix. Cloudflare deployed rules before the patch shipped and reported attackers changing payloads to get around them. If your site already sits behind Cloudflare, those rules help. They do not replace the update.</p>



<h3 class="wp-block-heading">The third issue: CVE-2026-115560</h3>



<p class="wp-block-paragraph">Two days after the release, PHP disclosed one more possible code-execution path. It needs an unusual setup: the application sets <code>session.serialize_handler</code> to <code>php_binary</code> or <code>php_serialize</code> in configuration, then switches it to <code>php</code> at runtime with <code>ini_set()</code> before calling <code>session_start()</code>, with session upload progress still enabled. The fixed releases above do not cover it. PHP&#8217;s advice is to keep <code>session.upload_progress.enabled=0</code> until the next regular bugfix release. If you already set it for CVE-2026-109619, leave it in place and you are done.</p>



<h2 class="wp-block-heading">After patching: assume someone tried</h2>



<p class="wp-block-paragraph">Amazon reported a honeypot hit against these flaws on 17 October, four days before disclosure, and exploitation attempts climbed fast once details were public. Most reports so far describe scanning and failed attempts rather than confirmed break-ins, but &#8220;nobody has reported it&#8221; is not evidence about your server. If you ran an unpatched, internet-facing PHP site during that window, do three things.</p>



<ul class="wp-block-list">
<li><strong>Rotate secrets a PHP worker could have held in memory:</strong> database passwords, API keys, payment provider keys, SMTP credentials and, for WordPress, the salts in <code>wp-config.php</code> (which logs every user out, so warn people first).</li>



<li><strong>Read your logs for the patterns researchers flagged:</strong> unusual multipart requests followed by memory-limit errors or bursts of HTTP 500 responses, oddly large <code>PHPSESSID</code> cookies in responses, and PHP workers restarting with no explanation.</li>



<li><strong>Look for anything new and executable</strong> in your web roots and upload directories, especially PHP files that no deploy put there.</li>
</ul>



<h2 class="wp-block-heading">Troubleshooting</h2>



<p class="wp-block-paragraph"><strong>&#8220;<code>php -v</code> still shows the old version.&#8221;</strong> Either it is a distro backport (check the changelog for the CVE), or you are looking at the CLI binary while a different build serves the site. Check the FPM binary directly and the container, not the host.</p>



<p class="wp-block-paragraph"><strong>&#8220;The site throws 500 errors after the update.&#8221;</strong> The fix changes how PHP handles running out of memory. Crossing <code>memory_limit</code> now raises a soft error at a safe point, and a second, higher threshold ends the process outright, without running shutdown handlers. A plugin or import job that used to squeak by can now fail differently. Check the error log, then review <code>memory_limit</code> and <code>post_max_size</code> against what the application genuinely needs.</p>



<p class="wp-block-paragraph"><strong>&#8220;Cleanup or logging code stopped running on big jobs.&#8221;</strong> Same cause. If anything important lives in a shutdown function, it will not run when the hard limit is hit. Fix the memory usage rather than raising limits blindly.</p>



<p class="wp-block-paragraph"><strong>&#8220;The package manager says there is nothing to update.&#8221;</strong> You are probably on a repository that has not shipped the fix, an end-of-life distro release, or an end-of-life PHP branch with no vendor backport. That is a migration problem, not a patching problem.</p>



<p class="wp-block-paragraph"><strong>&#8220;I set the mitigation but the old value still shows.&#8221;</strong> Look for overrides in other pool files, per-directory configuration or application code, and make sure you used the admin variants and restarted the workers.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Updating WordPress, plugins or the framework and calling it done. None of that touches the PHP runtime.</li>



<li>Trusting the version number in either direction, and declaring a backported package &#8220;vulnerable&#8221; or a stale process &#8220;patched.&#8221;</li>



<li>Upgrading the package and never restarting FPM, Apache or long-running workers.</li>



<li>Treating <code>session.upload_progress.enabled=0</code> as the fix. It was circulated as a workaround before the release and only covers part of one CVE.</li>



<li>Setting mitigations with <code>ini_set()</code>, <code>.htaccess</code> overrides that apps can change, or <code>.user.ini</code>, which take effect too late.</li>



<li>Leaving <code>memory_limit=-1</code> in place long after patching, with no cgroup boundary behind it.</li>



<li>Forgetting the boxes nobody owns: the staging server, the old client site on PHP 7.4, the forgotten container in a side project.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Keep an inventory of every PHP install, its source (distro, panel, container, source build) and its branch. This week shows why.</li>



<li>Verify fixes by CVE in the package changelog, and verify the running process by start time.</li>



<li>Patch mod_php servers first, then public FPM sites, then internal tools.</li>



<li>Once patched, restore a sane <code>memory_limit</code>, but keep session upload progress off until PHP&#8217;s next regular release covers CVE-2026-115560.</li>



<li>Move servers off mod_php to PHP-FPM where you can. It would not have saved you here, but it narrowed the easiest path.</li>



<li>Stay on a supported PHP branch, or pay for a vendor that backports. An unsupported branch turns every emergency into a migration.</li>
</ul>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What is the PHP2Shell vulnerability?</h3>



<p class="wp-block-paragraph">It is the nickname for two memory-safety flaws in the PHP engine, CVE-2026-109618 and CVE-2026-109619, both rated 9.2 on CVSS v4.0. They can allow unauthenticated remote code execution on servers that run PHP for web requests.</p>



<h3 class="wp-block-heading">Which PHP versions fix PHP2Shell?</h3>



<p class="wp-block-paragraph">PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 from php.net. Distribution and vendor packages may carry the fix under different, older-looking version numbers, so check the package changelog for the CVE.</p>



<h3 class="wp-block-heading">Is my WordPress site affected?</h3>



<p class="wp-block-paragraph">If it runs on an unpatched PHP build, yes, regardless of how up to date WordPress and its plugins are. The flaw is in PHP itself, so the fix comes from your server, host or container image.</p>



<h3 class="wp-block-heading">Is disabling session.upload_progress enough?</h3>



<p class="wp-block-paragraph">No. It blocks the confirmed session paths for one of the two bugs and covers the narrow third issue, CVE-2026-115560. It does nothing for CVE-2026-109618. Only an update is a complete fix.</p>



<h3 class="wp-block-heading">I&#8217;m on PHP 7.4 or 8.1. What now?</h3>



<p class="wp-block-paragraph">php.net has no release for you, but PHP published source backports for 7.4, 8.0 and 8.1, and several vendors and distributions shipped backported packages. Use one of those immediately, then plan the upgrade to a supported branch.</p>



<h3 class="wp-block-heading">Do I need to restart anything after updating PHP?</h3>



<p class="wp-block-paragraph">Yes. Restart every PHP-FPM pool, Apache if you use mod_php, LiteSpeed, and long-running PHP workers. Until they restart, they keep running the vulnerable code already loaded in memory.</p>



<h3 class="wp-block-heading">Should I rotate passwords and keys?</h3>



<p class="wp-block-paragraph">If an internet-facing server ran an unpatched build after details went public, it is a sensible precaution. Researchers showed worker memory could be read remotely, and that memory can hold credentials from other requests.</p>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">The PHP2Shell vulnerability lives below your application, so the only real fix is a patched PHP build that is actually running. Everything else in this post is either a way to get there or a way to buy time. If you remember one thing, make it this: verify the fix by CVE in the package, and verify the process by its restart time. The version string will mislead you in both directions.</p>



<h2 class="wp-block-heading">Need help patching PHP2Shell across your servers?</h2>



<p class="wp-block-paragraph">If you have more PHP servers than certainty, I can help you close this out properly:</p>



<ul class="wp-block-list">
<li>Inventory every PHP install across your VPS, cPanel, DirectAdmin and container hosts, with branch and package source.</li>



<li>Patch each one through the right channel and prove the fix by CVE and process restart time.</li>



<li>Apply and later roll back the interim mitigations safely, with a memory boundary in place.</li>



<li>Rebuild and redeploy PHP container images, including pinned-digest pipelines.</li>



<li>Review logs for the published indicators and plan secret rotation without breaking production.</li>



<li>Plan the move off end-of-life PHP branches and off mod_php.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>php -v</code>, your package changelog check, and how PHP runs on the server (FPM, mod_php or LiteSpeed), and I&#8217;ll tell you where you stand.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/php2shell-vulnerability-fix/">PHP2Shell Vulnerability: What Happened, How to Patch, and How to Prove It Worked</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</title>
		<link>https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 11 Oct 2026 06:18:09 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[MemAvailable]]></category>
		<category><![CDATA[Memory Management]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[OOM Killer]]></category>
		<category><![CDATA[Page Cache]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Swap]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[vmstat]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=432</guid>

					<description><![CDATA[<p>A screenshot of free -h showing 200 MB free triggers more unnecessary change requests than almost any other output in server operations. Here is what the free command is really reporting, why the available column is the one that matters, and the four signals that actually tell you a machine is running out of memory: available, swap rate, pressure stall information and the OOM log.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/">Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket came in flagged as urgent. A screenshot was attached: <code>free -h</code> on a database server, showing a little over 200 MB in the <strong>free</strong> column on a machine with 16 GB of RAM. Somebody had already drafted a change request to double the instance size before anyone looked at the rest of the output.</p>



<p class="wp-block-paragraph">The same output showed roughly 11 GB in the <strong>available</strong> column. Nothing was wrong, and nothing had been wrong for months. The only thing broken was the alert that fired.</p>



<p class="wp-block-paragraph">Low free memory on Linux is one of the most reliably misread signals in server operations. It looks like an emergency, it triggers scaling decisions, and it is almost always noise. This post covers what <code>free</code> is really telling you, which four signals actually indicate memory trouble, the one situation where a low free number genuinely matters, and what to put in your alert rules instead.</p>



<h2 class="wp-block-heading">What the free command is actually reporting</h2>



<p class="wp-block-paragraph"><code>free</code> does not measure anything. It parses <code>/proc/meminfo</code> and prints a handful of those fields in columns. Every number it shows comes straight from the kernel and is readable directly if you want to skip the formatting.</p>



<p class="wp-block-paragraph">A typical reading on a busy server looks roughly like this:</p>



<pre class="wp-block-code"><code>$ free -h
               total        used        free      shared  buff/cache   available
Mem:            15Gi       3.4Gi       231Mi       128Mi        12Gi        11Gi
Swap:          2.0Gi          0B       2.0Gi</code></pre>



<p class="wp-block-paragraph">Read that left to right and the story is boring. Applications hold a few gigabytes. The kernel filled almost everything else with page cache and buffers, because every file that gets read or written passes through memory on the way, and directory metadata lands in reclaimable slab. What is left over, genuinely untouched, is the free column.</p>



<p class="wp-block-paragraph">That last part is the bit people miss. <strong>Free memory is not a reserve. It is waste.</strong> A page holding a cached copy of a file your application reads every few seconds is doing useful work. A page sitting empty is doing nothing, and the kernel converts it into cache the moment it has a reason to. A server that has been up for a week and still shows plenty of free memory is a server that is barely touching its disk.</p>



<p class="wp-block-paragraph">If you want the raw source rather than the formatted view:</p>



<pre class="wp-block-code"><code>$ grep -E '^(MemTotal|MemFree|MemAvailable|Buffers|Cached|SReclaimable|SwapTotal|SwapFree|Dirty):' /proc/meminfo</code></pre>



<p class="wp-block-paragraph">Two options are worth knowing. <code>free -w</code> switches to wide mode and splits buffers and cache into separate columns, which helps when attributing memory to a specific I/O pattern. <code>free -h -s 5</code> reprints every five seconds, a crude but effective trend view while you reproduce a problem.</p>



<h2 class="wp-block-heading">Signal one: available memory, not free memory</h2>



<p class="wp-block-paragraph">The <strong>available</strong> column maps to <code>MemAvailable</code> in <code>/proc/meminfo</code>, and it is the kernel&#8217;s own estimate of how much memory a new workload could allocate without pushing the system into swap. It has been present since kernel 3.14, so every distribution you are likely to be running exposes it.</p>



<p class="wp-block-paragraph">What matters is that it is <em>not</em> free plus cache. That naive sum is what people calculated before <code>MemAvailable</code> existed, and it overstates the truth. The kernel starts from free pages, subtracts the reserves it refuses to dip below, then adds back the portion of file-backed page cache and reclaimable slab it believes it could actually release. Both additions are capped, because some cache is pinned and some reclaimable slab objects are in use.</p>



<p class="wp-block-paragraph">The kernel documentation is explicit that this is an estimate whose accuracy varies from system to system, so treat it as a strong hint rather than an exact figure. It is still far better than anything you can compute yourself, and it is the number your dashboards should be built on.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If you change one thing after reading this: point your memory alerts at available, not at free. Everything else in this post is refinement on top of that.</p>
</blockquote>



<h2 class="wp-block-heading">Signal two: swap activity, not swap usage</h2>



<p class="wp-block-paragraph">The second most common false alarm is swap. Someone sees a few hundred megabytes of swap in use and concludes the machine is thrashing. It usually isn&#8217;t.</p>



<p class="wp-block-paragraph">Swap <em>usage</em> is a stock: pages moved to disk at some point, possibly during a backup weeks ago, and never needed since. Leaving them there is correct, and pulling them back to satisfy a dashboard would evict something useful. Swap <em>activity</em> is a flow, and that is the part that hurts, because a page fault serviced from disk is orders of magnitude slower than one serviced from RAM.</p>



<p class="wp-block-paragraph"><code>vmstat</code> gives you the flow. The <code>si</code> and <code>so</code> columns are swap-in and swap-out, in blocks per second over the sampling interval:</p>



<pre class="wp-block-code"><code>$ vmstat 5 6</code></pre>



<p class="wp-block-paragraph">Discard the first row. <code>vmstat</code> reports averages since boot on its first line, which is almost never what you want. Read the rows after that. If <code>si</code> and <code>so</code> are both consistently non-zero, the machine is simultaneously reading pages back to serve current work and writing pages out to make room for new allocations. That is thrashing, and it is a real signal with no ambiguity attached.</p>



<p class="wp-block-paragraph">A single non-zero sample is not that. Brief swap activity during a log rotation or a backup window is normal. Sustained activity across several consecutive samples is what to escalate.</p>



<h2 class="wp-block-heading">Signal three: memory pressure, which is the one that catches things early</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the signal I reach for first now, and it is badly underused. It has been in the kernel since 4.20 and it answers a question none of the other tools answer directly: <em>how much time are my processes losing because memory is contended?</em></p>



<pre class="wp-block-code"><code>$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0</code></pre>



<p class="wp-block-paragraph">The <code>some</code> line is the share of wall-clock time in which at least one task was stalled waiting on memory. The <code>full</code> line is the share in which every non-idle task was stalled, meaning nothing useful happened at all. The averages cover ten seconds, one minute and five minutes, and <code>total</code> is a cumulative counter in microseconds that updates continuously, so it catches a two-second stall the running averages smooth away entirely.</p>



<p class="wp-block-paragraph">Here is why this beats a percentage threshold. Utilization tells you how much of a resource is consumed; pressure tells you whether that consumption is costing you anything. A machine can sit at high utilization for months with zero pressure, which means the sizing is fine. It can also sit at moderate utilization while <code>full</code> climbs, which means reclaim is working hard and latency is already suffering with no threshold crossed.</p>



<p class="wp-block-paragraph">With cgroup v2 mounted, the same data exists per cgroup in each directory&#8217;s <code>memory.pressure</code> file, so you can attribute pressure to a specific service rather than the whole machine. Prometheus node_exporter collects the system-level PSI counters, and if you already ship metrics to something like Grafana Cloud these graph well beside your latency panels.</p>



<h2 class="wp-block-heading">Signal four: the OOM log, which is the only unambiguous one</h2>



<p class="wp-block-paragraph">When memory really does run out, the kernel writes a record. It does not do this quietly and it does not do it conditionally. If there is no OOM record, no process was killed for memory, full stop.</p>



<pre class="wp-block-code"><code>$ sudo journalctl -k --since "24 hours ago" | grep -i "oom"
$ sudo dmesg -T | grep -i "killed process"</code></pre>



<p class="wp-block-paragraph">The kill line names the victim and reports its memory breakdown at the moment of death: total virtual size, anonymous resident set, file-backed resident set, shared memory resident set, and the <code>oom_score_adj</code> that was in effect. The line above it, where a process &#8220;invoked oom-killer&#8221;, tells you which allocation triggered the whole event, and that process is frequently not the one that got killed.</p>



<p class="wp-block-paragraph">That distinction is the most useful thing in the log. The kernel picks a victim by badness score, driven largely by how much memory a process holds relative to what is available to it, adjusted by <code>oom_score_adj</code>. A slow leak in a small helper can push the machine over the edge and get your database killed instead, because the database is simply the largest thing running. Read both lines before deciding who the culprit is.</p>



<p class="wp-block-paragraph">If you have a service that should survive at almost any cost, systemd exposes <code>OOMScoreAdjust=</code> in the unit file, which persists across restarts in a way that writing to <code>/proc/&lt;pid&gt;/oom_score_adj</code> does not. Use it sparingly. Protecting a process that is itself leaking just moves the crash somewhere less convenient.</p>



<h2 class="wp-block-heading">When low free memory on Linux really is the problem</h2>



<p class="wp-block-paragraph">Everything above assumes you are looking at a whole machine. Inside a container, the accounting changes, and this is the failure mode that is invisible until it isn&#8217;t.</p>



<p class="wp-block-paragraph">The cgroup memory controller charges page cache to the cgroup that caused it. Clean, evictable, perfectly reclaimable cache still counts toward <code>memory.current</code>, and that is what gets compared against <code>memory.max</code>. A container reading a large file has just charged that file to its own budget.</p>



<pre class="wp-block-code"><code>$ cat /sys/fs/cgroup/memory.current
$ cat /sys/fs/cgroup/memory.max
$ grep -E '^(anon|file|slab_reclaimable) ' /sys/fs/cgroup/memory.stat</code></pre>



<p class="wp-block-paragraph">The split in <code>memory.stat</code> is what you actually want. <code>anon</code> is memory your process genuinely holds; <code>file</code> is page cache charged to you that the kernel can take back under pressure. A container at its limit on mostly <code>file</code> is in a very different position from one at its limit on mostly <code>anon</code>.</p>



<p class="wp-block-paragraph">Two things follow from this that catch people out repeatedly:</p>



<ul class="wp-block-list">
<li><strong>Reclaim is not instant.</strong> Under heavy pressure the reclaim path itself runs slowly, so a container can be OOM-killed while still holding clean page cache that was theoretically available.</li>

<li><strong>Running <code>free</code> inside a container lies to you.</strong> Unless something virtualizes <code>/proc</code>, you are reading the host&#8217;s <code>/proc/meminfo</code>, which knows nothing about your limit. The cgroup files are the only truth here.</li>
</ul>



<p class="wp-block-paragraph">cgroup v2 also has <code>memory.high</code>, a soft limit. Crossing it triggers reclaim and throttling rather than a kill, which turns a hard failure into a slow one. That is usually the better trade, and it is worth setting deliberately rather than leaving it at its default.</p>



<h2 class="wp-block-heading">What to alert on</h2>



<p class="wp-block-paragraph">The alert on the ticket at the top of this post was watching the wrong number. Here is what a memory alert set looks like when it watches the right ones:</p>



<ul class="wp-block-list">
<li><strong>Available memory below a percentage of total, sustained.</strong> Ten percent for ten minutes is a reasonable starting shape. A single thirty-second dip is noise and should never page anyone.</li>

<li><strong>Sustained swap-in and swap-out together.</strong> Rate of change on the swap counters, not the absolute amount of swap in use.</li>

<li><strong>Memory PSI rising.</strong> The <code>some</code> line moving off the floor is your early warning. The <code>full</code> line moving at all is your late one.</li>

<li><strong>Any OOM kill, ever.</strong> This one is binary and should always generate a ticket, even if the service recovered on its own.</li>

<li><strong>Per-container memory events.</strong> The cgroup <code>memory.events</code> file counts limit hits, and a rising count is a limit that needs revisiting.</li>
</ul>



<p class="wp-block-paragraph">Notice that free memory does not appear anywhere in that list. There is no threshold on the free column that produces a useful alert, because the number is a byproduct of the kernel&#8217;s caching behaviour rather than a measure of anything you care about.</p>



<h2 class="wp-block-heading">Troubleshooting: which tool answers which question</h2>



<p class="wp-block-paragraph">When a memory question lands on you, the order you check things in matters more than the tools.</p>



<ol class="wp-block-list">
<li><strong>Did anything actually die?</strong> Check the kernel log for OOM records first. If there are none, this is a performance question and not a capacity failure, which changes everything about what you do next.</li>

<li><strong>Is the machine stalling?</strong> Read <code>/proc/pressure/memory</code>. Zero pressure with low free memory means you are done. Go close the ticket.</li>

<li><strong>Is it swapping right now?</strong> Run <code>vmstat 5</code> for half a minute and watch <code>si</code> and <code>so</code>. Ignore the first row.</li>

<li><strong>Who is holding the memory?</strong> <code>ps aux --sort=-%mem | head -15</code> gets the shortlist. Resident set size double-counts shared pages, so summing the column overstates the total.</li>

<li><strong>Is it the kernel rather than a process?</strong> If the process list does not account for the usage, check <code>slabtop</code> and <code>SUnreclaim</code> in <code>/proc/meminfo</code>. Unreclaimable slab growth is a different problem, usually a driver, filesystem or network workload creating enormous numbers of kernel objects.</li>

<li><strong>Is it growing?</strong> A leak is a slope, not a level, and one reading tells you nothing. This is the whole argument for having the graph before you need it.</li>
</ol>



<p class="wp-block-paragraph">One more that saves time on crowded boxes: resident set size is the wrong measure of what a process really costs, because shared libraries are counted against every process that maps them. Proportional set size divides shared pages among the processes sharing them, and tools reporting PSS give a far more honest answer when you are deciding what to move off a busy VPS.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Dropping caches to make the number look better.</strong> Writing to <code>/proc/sys/vm/drop_caches</code> discards clean page cache and reclaimable slab. The kernel documentation says plainly that using it outside testing or debugging is not recommended, because everything dropped has to be re-read from disk. You trade a nicer-looking <code>free</code> output for an I/O spike and worse latency. It is a benchmarking tool, not an operations tool.</li>

<li><strong>Setting <code>vm.swappiness</code> to zero as a reflex.</strong> It does not disable swap and it does not make the machine faster. It makes the kernel strongly prefer evicting page cache over swapping anonymous pages, which on a database host means throwing away exactly the cache you wanted. Change it for a measured reason, not by default.</li>

<li><strong>Running with no swap at all.</strong> Swap is not just an overflow area. Without it the kernel loses the option of paging out genuinely cold anonymous memory, which means the only lever left under pressure is evicting cache or killing something. A modest swap file gives reclaim somewhere to go.</li>

<li><strong>Summing RSS across processes and comparing it to total RAM.</strong> Shared pages are counted once per process. The sum will exceed reality, sometimes dramatically.</li>

<li><strong>Resizing the instance before finding the slope.</strong> More RAM buys time against a leak. It does not fix one, and the bill arrives every month afterwards.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Graph available memory, swap rate and memory PSI on one panel. Together they read as a story; individually each one misleads.</li>

<li>Keep the raw counters, not just derived percentages. A percentage of an instance size that has since changed cannot answer &#8220;was this happening last week&#8221;.</li>

<li>Set container limits from observed <code>anon</code> usage plus headroom, not a round number someone picked, and put <code>memory.high</code> below <code>memory.max</code> so pressure shows up as slowness before it shows up as a kill.</li>

<li>Alert on rate of change for anything that can leak. The steady climb is the signal; the level it reached is just when you happened to look.</li>

<li>Right-size deliberately. On providers such as Contabo or InterServer, where RAM is a meaningful share of the monthly bill, the gap between a real requirement and a panicked upgrade is money you keep every month.</li>

<li>Write the explanation down where your team will find it. Every organization rediscovers the free-versus-available confusion independently, usually at three in the morning.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Why is free memory so low on my Linux server?</h3>



<p class="wp-block-paragraph">Because the kernel used it for page cache and buffers, which is the intended behaviour. Any file read or written passes through memory, and the kernel keeps those pages in case they are needed again. It reclaims them automatically the moment an application needs the space. Check the available column instead.</p>



<h3 class="wp-block-heading">What is the difference between free and available in free -h?</h3>



<p class="wp-block-paragraph">Free is memory that is completely unused. Available is the kernel&#8217;s estimate of how much a new process could allocate without swapping: free memory plus the portion of cache and reclaimable slab it believes it could release. Available is almost always much larger, and it answers the question you are actually asking.</p>



<h3 class="wp-block-heading">Should I clear the page cache to free up memory?</h3>



<p class="wp-block-paragraph">No. The kernel reclaims cache automatically under pressure, and more selectively than a blanket drop. Writing to <code>drop_caches</code> forces everything to be re-read from disk, and the kernel documentation advises against using it outside testing or debugging. Doing it on a schedule is a strong hint something else is misconfigured.</p>



<h3 class="wp-block-heading">Is swap being used a bad sign?</h3>



<p class="wp-block-paragraph">Not by itself. Pages that were swapped out during a quiet period and never needed again are fine where they are. What matters is swap traffic: sustained non-zero <code>si</code> and <code>so</code> in <code>vmstat</code> at the same time means the machine is thrashing. Alert on the rate, not the amount.</p>



<h3 class="wp-block-heading">How do I tell if a process was killed for running out of memory?</h3>



<p class="wp-block-paragraph">Search the kernel log. <code>journalctl -k</code> filtered for &#8220;oom&#8221;, or <code>dmesg -T</code> filtered for &#8220;killed process&#8221;, shows a record naming the process, its PID and its memory breakdown at the time. The kernel always logs this, so if there is no record the process died for some other reason.</p>



<h3 class="wp-block-heading">Why does my container get OOM-killed when memory looks fine inside it?</h3>



<p class="wp-block-paragraph">Because <code>free</code> inside a container usually reads the host&#8217;s memory information, not the cgroup limit being enforced. Read <code>memory.current</code>, <code>memory.max</code> and <code>memory.stat</code> instead. Page cache charged to the container counts toward its limit, so a read-heavy workload can hit the ceiling without any process holding much anonymous memory.</p>



<h3 class="wp-block-heading">What is a good memory alert threshold?</h3>



<p class="wp-block-paragraph">Available memory below roughly ten percent of total, sustained for ten minutes, is a defensible starting point to tune against your own workload. Pair it with memory PSI and an unconditional alert on any OOM kill. A threshold on the free column, at any value, will page you for nothing.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">Low free memory on Linux is the expected steady state of a healthy, working server. The kernel fills memory with cache because empty memory earns nothing, and hands it back the instant an application asks. The free column is a byproduct of that behaviour, not a health metric, and alerting on it produces exactly the kind of urgent ticket that wastes an afternoon and ends in a change request nobody needed.</p>



<p class="wp-block-paragraph">The one thing worth remembering: <strong>free tells you what is idle, available tells you what you can have, and pressure tells you whether any of it is costing you.</strong> Watch the second and third, log the OOM events, and read the cgroup files rather than <code>free</code> whenever a container is involved.</p>



<h2 class="wp-block-heading">Need help reading your memory signals?</h2>



<p class="wp-block-paragraph">Most of the memory work I get asked for is not &#8220;add more RAM&#8221;. It is working out whether the number someone is worried about means anything. I can help with:</p>



<ul class="wp-block-list">
<li>Rebuilding memory alerts around available memory, swap rate and PSI so they stop paging on normal cache behaviour</li>

<li>Diagnosing repeated OOM kills, including the cases where the process being killed is not the one causing the problem</li>

<li>Setting container and pod memory limits from observed anonymous usage rather than guesses, with a soft limit so pressure surfaces before a kill does</li>

<li>Separating a genuine leak from normal cache growth using retained metrics rather than a single reading</li>

<li>Right-sizing servers and instances honestly, up or down, based on what the workload actually needs</li>

<li>Building the dashboard that turns the next memory question into a five-minute conversation instead of an incident</li>
</ul>



<p class="wp-block-paragraph">If you have a <code>free -h</code> output, a <code>vmstat</code> capture or an OOM log line that is bothering you, send it over and I will tell you what it means.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/low-free-memory-on-linux/">Low Free Memory on Linux Isn&#8217;t a Problem. Here&#8217;s What Actually Is.</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cron vs systemd Timers: The Failure Modes That Decide It</title>
		<link>https://john-nessime.com/blog/devops/cron-vs-systemd-timers/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 28 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[anacron]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Cron]]></category>
		<category><![CDATA[Crontab]]></category>
		<category><![CDATA[flock]]></category>
		<category><![CDATA[Job Scheduling]]></category>
		<category><![CDATA[journalctl]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Reliability Engineering]]></category>
		<category><![CDATA[Shell Scripting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[systemd Timers]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=254</guid>

					<description><![CDATA[<p>Both cron and systemd timers can run a command at 2 a.m. The difference is what happens the night it doesn't run, and how long it takes anyone to notice. A comparison by failure family, with a decision rule, the same job written both ways, and how to debug each one when it goes quiet.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/cron-vs-systemd-timers/">Cron vs systemd Timers: The Failure Modes That Decide It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket usually arrives from the wrong direction. Nobody reports &#8220;the scheduler is broken.&#8221; Somebody asks for a restore, and the newest dump in the backup directory is three weeks old.</p>



<p class="wp-block-paragraph">The crontab entry is still there. The script still works when you run it by hand. Nothing in the monitoring went red, because nothing was watching whether the job ran at all. It was watching whether the server was up.</p>



<p class="wp-block-paragraph">That is the real question behind cron vs systemd timers, and it has almost nothing to do with syntax. Both tools can run a command at 2 a.m. The difference is what happens on the night the command doesn&#8217;t run, and how long it takes anyone to find out.</p>



<p class="wp-block-paragraph">This post compares the two by failure family rather than by feature list: missed runs, overlapping runs, environment surprises, output that goes nowhere, and jobs that eat the machine. Then a decision procedure, the same job written both ways, and how to debug each one when it goes quiet.</p>



<h2 class="wp-block-heading">The failure that actually decides it</h2>



<p class="wp-block-paragraph">Scheduled jobs fail silently far more often than they fail loudly. A job that crashes with a stack trace gets fixed the same week. A job that stopped firing six weeks ago gets found during an incident.</p>



<p class="wp-block-paragraph">Cron&#8217;s default answer to &#8220;what happened?&#8221; is to mail the job&#8217;s output to the owning user. On a server with no local mail transfer agent configured, which is most servers built in the last decade, that output is generated and then discarded. The daemon logs that it <em>started</em> the command. It does not log what the command printed, and in most implementations it does not log the exit status either.</p>



<p class="wp-block-paragraph">So you get a log line saying cron ran your backup, every night, forever, including on the nights the backup wrote nothing. That line is the trap. It looks like evidence.</p>



<p class="wp-block-paragraph">systemd timers hand this to journald instead. Standard output and standard error from the triggered service land in the journal, tagged with the unit name, alongside the exit status and the runtime. That is the single biggest practical difference between the two, and it is the reason most of my new scheduled work goes into timers.</p>



<p class="wp-block-paragraph">Neither one alerts you. Both will happily not run for a month while you sleep well. Observability is not monitoring, and I&#8217;ll come back to that.</p>



<h2 class="wp-block-heading">Where cron still wins</h2>



<p class="wp-block-paragraph">Cron is one line in one file. There is no unit to reload, no second file to keep in sync, no install section to get wrong. <code>crontab -e</code>, five fields, a command, done. For a job that trims a log directory on a box you own, that economy is not nothing.</p>



<p class="wp-block-paragraph">It also runs where systemd does not. BSD hosts, Alpine images, containers, appliance firmware, embedded boxes, older enterprise systems still on a legacy init. If your configuration management has to cover a fleet that is not uniformly systemd, cron is the common denominator and it is not close.</p>



<p class="wp-block-paragraph">Per-user scheduling is genuinely simpler too. A user runs <code>crontab -e</code> and owns their own jobs, with no root involvement and no unit files in <code>/etc</code>. On shared hosting and on control panels like DirectAdmin or cPanel, this is the only model available, and the panel writes the crontab for you.</p>



<h3 class="wp-block-heading">Where cron loses</h3>



<ul class="wp-block-list">

<li>No dependency ordering. Cron cannot wait for the network to be up or the database to be ready. It fires at the wall clock time and hopes.</li>


<li>No overlap protection. If the 2 a.m. run is still going at 3 a.m., the 3 a.m. run starts anyway, on top of it.</li>


<li>No resource containment. A runaway job competes with production for CPU, memory and disk with nothing standing between them.</li>


<li>Minute resolution, and no catch-up if the machine was off.</li>


<li>A syntax with sharp edges that fail quietly, which is the next section.</li>

</ul>



<h2 class="wp-block-heading">Where systemd timers win</h2>



<p class="wp-block-paragraph">A timer separates <em>when</em> from <em>what</em>. The <code>.timer</code> unit holds the schedule. The <code>.service</code> unit holds the job. That split feels like bureaucracy until the first time you need to run the job right now, out of band, without touching the schedule. Then it is just <code>systemctl start myjob.service</code>, and the run is logged exactly like a scheduled one.</p>



<p class="wp-block-paragraph">Everything systemd knows how to do to a service, it will do to a scheduled job. Ordering after other units. Restart-on-failure. Runtime caps. Memory and CPU limits through cgroups. A dedicated user. A private <code>/tmp</code>. A failure handler unit that fires when the job exits non-zero.</p>



<p class="wp-block-paragraph">You also get a schedule you can interrogate before it bites you. <code>systemctl list-timers</code> prints every timer on the box with its next and last elapse. <code>systemd-analyze calendar</code> takes an expression and tells you exactly when it would fire, which is the closest thing either scheduler has to a unit test.</p>



<h3 class="wp-block-heading">Where systemd timers lose</h3>



<ul class="wp-block-list">

<li>Two files, a <code>daemon-reload</code> and an <code>enable</code> for every job. That is real friction when you have forty small jobs.</li>


<li>The calendar syntax is more expressive than cron&#8217;s and harder to eyeball. You will reach for <code>systemd-analyze calendar</code> more often than you expect.</li>


<li>Portability is gone. These units run on systemd hosts and nowhere else.</li>


<li>User timers need lingering enabled, or they stop when the user logs out and never start at boot. This surprises people once, memorably.</li>


<li>The journal is not a log file. If your log shipping only tails <code>/var/log</code>, timer output is invisible to it until you point the shipper at journald.</li>

</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Cron vs systemd timers, by failure family</h2>



<h3 class="wp-block-heading">The machine was off at 2 a.m.</h3>



<p class="wp-block-paragraph">Cron has no memory. If the box was down, rebooting, or suspended when the schedule came round, that run is gone. Nothing catches it up and nothing records that it was skipped.</p>



<p class="wp-block-paragraph">The traditional fix is anacron, which tracks the last successful run per job in a timestamp file and runs the job shortly after boot if the interval has lapsed. It is why the scripts in <code>/etc/cron.daily</code> still run on a laptop that is closed every night. The catch is that anacron works in whole days and only covers the <code>cron.daily</code>, <code>cron.weekly</code> and <code>cron.monthly</code> directories by default. It does not help your custom job at 02:00.</p>



<p class="wp-block-paragraph">systemd folds that behaviour into a single directive:</p>



<pre class="wp-block-code"><code>[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true</code></pre>



<p class="wp-block-paragraph">With <code>Persistent=true</code>, systemd writes a timestamp under <code>/var/lib/systemd/timers/</code> each time the timer fires. On boot it compares that stamp against the schedule, and if a run was missed it triggers immediately. This matters more than it sounds on any VPS you reboot for kernel updates, and a great deal on hosts at providers like Contabo or InterServer where you take maintenance windows on their timetable rather than yours.</p>



<p class="wp-block-paragraph">One caveat worth knowing: &#8220;immediately&#8221; means immediately. If you have twelve persistent timers and the box has been off for a week, all twelve fire within seconds of boot, on a machine that is still starting services. Pair <code>Persistent=true</code> with <code>RandomizedDelaySec=</code> so the catch-up is spread out.</p>



<h3 class="wp-block-heading">Two copies of the same job</h3>



<p class="wp-block-paragraph">This is the one that corrupts data. A sync job normally takes four minutes and runs every five. One night the remote end is slow, the run takes eleven minutes, and cron starts two more copies while the first is still writing.</p>



<p class="wp-block-paragraph">Cron will not stop this. You have to do it yourself, and the standard tool is <code>flock</code> from util-linux:</p>



<pre class="wp-block-code"><code>*/5 * * * * /usr/bin/flock -n /var/lock/sync.lock /usr/local/bin/sync.sh</code></pre>



<p class="wp-block-paragraph"><code>-n</code> means fail immediately rather than queue behind the running copy. Without it you build a backlog of processes all waiting for the same lock, which turns an overlap problem into a fork bomb with a slow fuse.</p>



<p class="wp-block-paragraph">systemd gets this for free from its own model. A unit is either active or it isn&#8217;t. If <code>sync.service</code> is still running when the timer elapses, there is no second copy to start, because starting an already-active unit is a no-op. You do not have to remember anything.</p>



<p class="wp-block-paragraph">One default is worth knowing. A calendar timer schedules its next elapse from the previous trigger time, so if a run overran, that next elapse is already in the past and the service fires again the moment it finishes. Newer systemd releases add a <code>DeferReactivation=</code> boolean that schedules from when the service went inactive instead, so an overrunning job waits for the next real slot. Check your distribution&#8217;s systemd version before relying on it.</p>



<h3 class="wp-block-heading">The environment your job actually gets</h3>



<p class="wp-block-paragraph">&#8220;It works when I run it manually&#8221; is the single most common scheduled-job bug, and both schedulers cause it, for the same reason: neither one gives your job an interactive login shell.</p>



<p class="wp-block-paragraph">Cron runs the command with a short, hardcoded environment. Your shell profile is not sourced. Your <code>PATH</code> is much shorter than the one you tested with, which is why a script that calls <code>docker</code>, <code>wp</code>, <code>aws</code> or a version-manager shim works from your terminal and fails from cron. The working directory is the user&#8217;s home, not wherever you happened to be standing.</p>



<p class="wp-block-paragraph">Cron adds one hazard that is entirely its own. Inside a crontab, the percent sign is a metacharacter: it becomes a newline, and everything after the first one is fed to the command as standard input. So this looks fine and is broken:</p>



<pre class="wp-block-code"><code># Truncated at the first %. The redirect never happens.
0 2 * * * /usr/bin/mysqldump app &gt; /backups/app-$(date +%F).sql

# Escaped. This is what you meant.
0 2 * * * /usr/bin/mysqldump app &gt; /backups/app-$(date +%F).sql</code></pre>



<p class="wp-block-paragraph">There is no warning. The line parses, cron runs the truncated fragment, and the log says the job started. Anything with a <code>date +</code> format string in a crontab deserves a second look.</p>



<p class="wp-block-paragraph">systemd services get a minimal environment too, so the class of bug is identical. What differs is that the fix is declarative and lives with the unit rather than being hidden inside a wrapper script:</p>



<pre class="wp-block-code"><code>[Service]
Type=oneshot
User=deploy
WorkingDirectory=/srv/app
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=/etc/myapp/backup.env
ExecStart=/usr/local/bin/backup.sh</code></pre>



<p class="wp-block-paragraph">The honest summary: absolute paths for every binary solve most of this on either scheduler. The rest is solved by never putting logic in the schedule. Put it in a script and schedule the script.</p>



<h3 class="wp-block-heading">Everything firing at once</h3>



<p class="wp-block-paragraph">Write <code>0 0 * * *</code> on fifty hosts and fifty hosts hit your backup target, your package mirror or your API at exactly midnight. The classic cron answer is to hash the hostname into a minute offset in configuration management, which works and which every team reinvents.</p>



<p class="wp-block-paragraph">systemd has two directives here and they pull in opposite directions, which is the part people get wrong:</p>



<ul class="wp-block-list">

<li><code>RandomizedDelaySec=</code> spreads timers <em>apart</em>. It adds a delay between zero and the value you set, so identical schedules on many hosts land at different moments.</li>


<li><code>AccuracySec=</code> pulls timers <em>together</em>. It lets systemd shift the firing time within a window to coalesce wakeups and save power. It defaults to one minute, which is why a timer set to 02:00:00 often fires a few seconds late.</li>

</ul>



<p class="wp-block-paragraph">If you want a timer to fire close to the stated second, lower <code>AccuracySec=</code>. If you want to stagger a fleet, raise <code>RandomizedDelaySec=</code>. Setting only <code>AccuracySec=</code> to a large value and expecting it to spread load is the mistake, because coalescing is the opposite of spreading.</p>



<h3 class="wp-block-heading">A job that eats the box</h3>



<p class="wp-block-paragraph">Cron has no answer here beyond <code>nice</code> and <code>ionice</code> in front of the command, and a script that hangs forever will hang forever. A systemd service is a cgroup, so the limits are declarative and enforced:</p>



<pre class="wp-block-code"><code>[Service]
Type=oneshot
MemoryMax=512M
CPUQuota=40%
IOWeight=20
TimeoutStartSec=30min
OnFailure=notify-failure@%n.service
ExecStart=/usr/local/bin/reindex.sh</code></pre>



<p class="wp-block-paragraph">The two directives that earn their keep on a shared box are <code>TimeoutStartSec=</code>, which kills a <code>oneshot</code> job that has hung, and <code>OnFailure=</code>, which starts another unit when this one exits non-zero. That second one is how you get a scheduled job to page you without wiring an alerting call into every script.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The same job written both ways</h2>



<p class="wp-block-paragraph">A nightly database dump that must not overlap, must not run before the database is up, and must catch up if the host was rebooted. In cron:</p>



<pre class="wp-block-code"><code>0 2 * * * /usr/bin/flock -n /var/lock/db-backup.lock /usr/local/bin/db-backup.sh &gt;&gt; /var/log/db-backup.log 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">That handles overlap and captures output. It does not handle &#8220;the database wasn&#8217;t ready&#8221; or &#8220;the host was down&#8221;, and the log file is now your problem to rotate.</p>



<p class="wp-block-paragraph">The systemd version is two files. First the service, which is the job:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/db-backup.service
[Unit]
Description=Nightly application database dump
After=network-online.target mariadb.service
Wants=network-online.target

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/db-backup.sh
TimeoutStartSec=45min</code></pre>



<p class="wp-block-paragraph">Then the timer, which is only the schedule:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/db-backup.timer
[Unit]
Description=Run the nightly database dump

[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=15min

[Install]
WantedBy=timers.target</code></pre>



<p class="wp-block-paragraph">Because the two units share a base name, you do not need a <code>Unit=</code> line. systemd pairs <code>db-backup.timer</code> with <code>db-backup.service</code> by convention. Add <code>Unit=</code> only when the names differ.</p>



<p class="wp-block-paragraph">Then the part people skip, which is verifying it before walking away:</p>



<pre class="wp-block-code"><code># Check the units parse before you enable anything
systemd-analyze verify /etc/systemd/system/db-backup.*

# Confirm the calendar expression means what you think
systemd-analyze calendar --iterations=5 "*-*-* 02:00:00"

systemctl daemon-reload
systemctl enable --now db-backup.timer

# Next and last elapse for this timer
systemctl list-timers db-backup.timer

# Run it now, out of band, without touching the schedule
systemctl start db-backup.service
journalctl -u db-backup.service -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">Note that you enable the <em>timer</em>, not the service. Enabling the service instead would try to run the job at every boot, which is a memorable way to learn the difference.</p>



<h2 class="wp-block-heading">How I&#8217;d decide</h2>



<p class="wp-block-paragraph">Work down this list and stop at the first line that applies. It resolves most cases in under a minute.</p>



<ol class="wp-block-list">

<li>The host might not be systemd, now or later. Use cron. Portability beats every other consideration on this list.</li>


<li>The job is inside a container. Use neither. Use your orchestrator&#8217;s scheduler, or a timer on the host that invokes the container.</li>


<li>A missed run matters, or the job must not overlap, or it needs to wait for another service. Use a timer. Every one of those is a directive rather than a wrapper script.</li>


<li>The job is heavy: a dump, a reindex, an import, anything that can pin a core or fill memory. Use a timer, for the cgroup limits and the runtime cap.</li>


<li>You need to see output after the fact without building a logging path first. Use a timer. The journal is already there.</li>


<li>You need sub-minute scheduling. Use a timer with a monotonic directive. Cron&#8217;s floor is one minute.</li>


<li>Otherwise, cron is fine, and one line beats two files.</li>

</ol>



<p class="wp-block-paragraph">The migration question answers itself from that. Do not convert forty working crontab lines in a weekend. Write new scheduled work as timers, convert the jobs that have actually burned you, and leave the rest. Mixing both on one host is normal and supported. Just never schedule the same job in both places, which is easier to do than it sounds when a configuration management run and a manual edit disagree.</p>



<h2 class="wp-block-heading">Diagnosing a job that never fired</h2>



<h3 class="wp-block-heading">Cron</h3>



<p class="wp-block-paragraph">Establish what actually happened before you theorise. The daemon logs every job it starts, so the log tells you which of three situations you are in: cron never tried, cron tried and the command failed, or cron never saw the entry at all.</p>



<pre class="wp-block-code"><code># Is the daemon running at all?
systemctl status crond      # RHEL, AlmaLinux, Rocky
systemctl status cron       # Debian, Ubuntu

# What did it start, and when?
journalctl -u crond --since "24 hours ago"
grep CRON /var/log/syslog | tail -20     # Debian family
tail -50 /var/log/cron                   # RHEL family

# Whose crontab are you actually looking at?
crontab -l -u deploy
ls -l /etc/cron.d/</code></pre>



<p class="wp-block-paragraph">Then work through the usual suspects, roughly in order of how often they turn out to be the cause:</p>



<ul class="wp-block-list">

<li><strong>Wrong crontab.</strong> The job is in root&#8217;s crontab and you are reading the deploy user&#8217;s, or it&#8217;s in <code>/etc/cron.d</code> where there is an extra user field before the command.</li>


<li><strong>PATH, or an unescaped percent sign.</strong> Dump what the job really sees with a temporary entry like <code>* * * * * env &gt; /tmp/cron-env.txt 2&gt;&amp;1</code>, then compare it against your shell.</li>


<li><strong>Permissions.</strong> The script is not executable, or the crontab owner cannot read it.</li>


<li><strong>The schedule is not what you think.</strong> When both day-of-month and day-of-week are restricted, cron runs the job when <em>either</em> matches, not both. <code>0 0 15 * 1</code> fires on the 15th and on every Monday.</li>


<li><strong>The command captured no output</strong>, so a real error was mailed into the void. Append <code>&gt;&gt; /var/log/myjob.log 2&gt;&amp;1</code> and wait for the next run.</li>

</ul>



<h3 class="wp-block-heading">systemd</h3>



<pre class="wp-block-code"><code># Is the timer loaded and scheduled? --all shows inactive ones too
systemctl list-timers --all

# Timer state, last trigger, next elapse
systemctl status db-backup.timer

# What the job printed, and how it exited
journalctl -u db-backup.service --since "7 days ago"

# Prove the schedule independently of the unit
systemd-analyze calendar --iterations=3 "Mon..Fri *-*-* 06:30:00"</code></pre>



<ul class="wp-block-list">

<li><strong>The timer is not enabled.</strong> Creating the files does nothing. It needs <code>daemon-reload</code> and <code>enable --now</code>.</li>


<li><strong>You enabled the service instead of the timer.</strong> Check with <code>systemctl is-enabled</code> on both.</li>


<li><strong>A user timer with no lingering.</strong> Under <code>systemctl --user</code>, the manager stops when the session ends unless you run <code>loginctl enable-linger</code> for that user.</li>


<li><strong>The calendar expression is valid but wrong.</strong> Valid syntax that never matches will not error. <code>systemd-analyze calendar</code> is how you find out.</li>


<li><strong>The service failed before your script ran.</strong> A missing <code>User=</code>, an <code>ExecStart</code> path that does not exist, a <code>WorkingDirectory</code> that is not there. The journal names the reason.</li>

</ul>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<p class="wp-block-paragraph"><strong>&#8220;Cron is deprecated.&#8221;</strong> It isn&#8217;t. It is packaged and supported on every mainstream distribution, and control panels, hosting providers and countless application installers write crontab entries as a matter of course. Nobody is removing it.</p>



<p class="wp-block-paragraph"><strong>&#8220;Timers are too complicated for small jobs.&#8221;</strong> Two files is more typing, not more complexity. The complexity that matters is the wrapper script you write to get locking, logging and a runtime cap out of cron, and how well the next person understands it. Compare like for like.</p>



<p class="wp-block-paragraph"><strong>&#8220;The journal means I have logging sorted.&#8221;</strong> The journal is a ring buffer with a size cap. If nobody set <code>SystemMaxUse=</code> deliberately and nobody ships journal entries anywhere else, your evidence has a retention window you never chose. Check it before you rely on it.</p>



<p class="wp-block-paragraph"><strong>&#8220;Switching to timers will stop jobs failing silently.&#8221;</strong> This is the one I&#8217;d push back on hardest. Timers make failures <em>visible</em>. They do not make them <em>noticed</em>. Neither scheduler will ever tell you that a job did not run, because absence is not an event. That needs a dead man&#8217;s switch: the job pings a heartbeat endpoint on success, and something external alerts when the ping stops. Healthchecks.io, Cronitor and Better Stack all sell exactly this, and Healthchecks is open source if you would rather self-host it next to your own Grafana. If you already run Prometheus, writing a completion timestamp through the node exporter&#8217;s textfile collector and alerting on its age gets you the same property without another vendor.</p>



<p class="wp-block-paragraph"><strong>&#8220;Just move everything to timers.&#8221;</strong> A mass migration of working jobs is a lot of change for no new capability, and every converted job is a chance to fat-finger a path. Convert on cause, not on principle.</p>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Are systemd timers better than cron?</h3>



<p class="wp-block-paragraph">For production jobs on a systemd host, generally yes: logging, dependency ordering, overlap protection, catch-up and resource limits are all built in rather than bolted on. For a one-line job on a box you own, cron&#8217;s simplicity is a real advantage and the timer buys you nothing you will use.</p>



<h3 class="wp-block-heading">Is cron deprecated or being removed?</h3>



<p class="wp-block-paragraph">No. Cron implementations are still packaged and maintained across the major distributions. Some distributions do not install a cron daemon in minimal images by default, which is a packaging decision rather than a deprecation, so check with your package manager before assuming a fresh host has one.</p>



<h3 class="wp-block-heading">Can cron and systemd timers coexist on the same server?</h3>



<p class="wp-block-paragraph">Yes, and most real servers run both. The cron daemon itself is usually managed as a systemd service. The only rule is that a given job should be scheduled in exactly one place. Double-scheduling is what produces the &#8220;why did this run twice&#8221; ticket.</p>



<h3 class="wp-block-heading">How do I see when a systemd timer will next run?</h3>



<p class="wp-block-paragraph"><code>systemctl list-timers</code> shows next and last elapse for all active timers; add <code>--all</code> to include inactive ones. To test an expression without creating a unit, use <code>systemd-analyze calendar "Mon..Fri *-*-* 06:30:00"</code> with <code>--iterations=N</code> for more fire times.</p>



<h3 class="wp-block-heading">How do I stop a cron job from overlapping itself?</h3>



<p class="wp-block-paragraph">Wrap it in <code>flock -n</code> with a dedicated lock file. The <code>-n</code> flag makes the new run exit straight away instead of queuing. Do not use a PID file you wrote yourself; it will leak a stale lock the first time the job is killed, and then the job stops running forever.</p>



<h3 class="wp-block-heading">Do systemd timers work inside Docker containers?</h3>



<p class="wp-block-paragraph">Not usefully. A container normally runs a single process without systemd as PID 1, so there is no service manager to own a timer. Schedule from outside the container instead: a host timer that runs the container, or the scheduler your platform provides. Podman is the exception worth knowing, since it can generate systemd units for containers and the host&#8217;s systemd drives them.</p>



<h3 class="wp-block-heading">How do I convert a crontab line to a systemd timer?</h3>



<p class="wp-block-paragraph">Move the command into a <code>Type=oneshot</code> service with an absolute <code>ExecStart</code>, then translate the five cron fields into an <code>OnCalendar=</code> expression of the form <code>DayOfWeek Year-Month-Day Hour:Minute:Second</code>. Verify the expression with <code>systemd-analyze calendar</code>, enable the timer, then remove the crontab line only after you have seen a successful run in the journal.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">Cron vs systemd timers is not a contest between an old tool and a new one. Cron is a scheduler. A systemd timer is a scheduler attached to a supervisor, and the supervisor is what you are actually buying: dependency ordering, overlap protection, catch-up, resource limits, and output that lands somewhere you can read it.</p>



<p class="wp-block-paragraph">Pick timers when a missed or duplicated run costs you something. Pick cron when it doesn&#8217;t. And whichever you pick, add a heartbeat, because the failure that hurts is not the job that crashes. It&#8217;s the job that stopped running while everything stayed green.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a second pair of eyes on your scheduled jobs?</h2>



<p class="wp-block-paragraph">Scheduling is one of those areas where the problem is rarely the syntax. It&#8217;s the job nobody has checked since the person who wrote it left. This is the kind of work I take on:</p>



<ul class="wp-block-list">

<li>Auditing every crontab and timer on a host and telling you which jobs have quietly stopped running</li>


<li>Converting fragile cron entries to systemd timers with locking, runtime caps and failure handlers, without a big-bang migration</li>


<li>Fixing the classic &#8220;works by hand, fails on schedule&#8221; environment and PATH problems, including the percent-sign trap</li>


<li>Adding heartbeat monitoring and alerting so a missed run pages someone instead of sitting there</li>


<li>Backup and dump jobs that verify their own output rather than exiting zero on an empty file</li>


<li>Untangling duplicate schedules left behind by control panels, deploy scripts and configuration management</li>

</ul>



<p class="wp-block-paragraph">If something on your server isn&#8217;t running when it should, send me the crontab line or the unit file and the journal output around the time it should have fired. That&#8217;s usually enough to tell you what&#8217;s wrong before we talk about anything else.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/cron-vs-systemd-timers/">Cron vs systemd Timers: The Failure Modes That Decide It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Troubleshooting High I/O Wait Without Blaming the Disk</title>
		<link>https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 22 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[bpftrace]]></category>
		<category><![CDATA[cgroups]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk I/O]]></category>
		<category><![CDATA[eBPF]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[iostat]]></category>
		<category><![CDATA[Kernel Tuning]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pressure Stall Information]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=248</guid>

					<description><![CDATA[<p>High I/O wait is a symptom, not a diagnosis. It rises when nothing is wrong and stays flat when something is badly wrong, and it only sees one layer of the storage stack. This is how to read iowait and PSI correctly, then work through the five failure families that actually produce storage stalls: device saturation, memory pressure, synchronous writes, throttling, and latency that lives above the block layer entirely.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The ticket says the app is slow. You SSH in, run <code>top</code>, and there it is: <code>wa</code> sitting at forty percent while user CPU barely moves. Obvious conclusion, the disk is dying. Somebody opens a ticket with the host, everyone waits, and the host replies that the volume looks healthy on their side.</p>



<p class="wp-block-paragraph">Sometimes the disk really is dying. More often it isn&#8217;t, and the number you&#8217;re staring at is not measuring what you think it&#8217;s measuring.</p>



<p class="wp-block-paragraph">This post covers troubleshooting high I/O wait the way I&#8217;d actually do it on a production box: what the metric means, why it lies in both directions, and how to work through the five failure families that produce it.</p>



<h2 class="wp-block-heading">What high I/O wait actually measures</h2>



<p class="wp-block-paragraph">Here&#8217;s the definition that matters: iowait is a <em>subcategory of idle time</em>. For a given CPU, it&#8217;s the time that CPU spent doing nothing while at least one task scheduled on it had an outstanding block I/O request.</p>



<p class="wp-block-paragraph">Two consequences fall out of that, and both will burn you. <strong>High iowait can mean nothing is wrong.</strong> If the box has spare CPU and one process is pulling a large file off disk, the CPU genuinely has nothing else to do. Copying a backup will push iowait toward 100 percent on an idle host, and that&#8217;s a working system, not a fault.</p>



<p class="wp-block-paragraph"><strong>Low iowait can hide a severe problem.</strong> This is the one that costs people days. If the CPU has other runnable work, the kernel context switches to it and that time gets counted as user or system, not iowait. A busy application server can be stalling badly on storage while <code>wa</code> reads two percent, because the CPU is never actually idle.</p>



<p class="wp-block-paragraph">Add the averaging problem on top. The <code>wa</code> figure in <code>top</code> is averaged across every core. One thread pinned to one core and blocked hard on disk shows up as roughly three percent on a 32-core machine. Press <code>1</code> in <code>top</code> to break out per-CPU lines before you conclude anything from the aggregate.</p>



<p class="wp-block-paragraph">So iowait is a hint, not a verdict. Treat it as the thing that starts the investigation, never the thing that ends it.</p>



<h2 class="wp-block-heading">Start with pressure, not percentages</h2>



<p class="wp-block-paragraph">Pressure Stall Information is the metric I reach for first, and it exists precisely because iowait is a poor proxy for pain. It answers the question you actually care about: how much time did tasks lose because they couldn&#8217;t get I/O?</p>



<pre class="wp-block-code"><code>cat /proc/pressure/io</code></pre>



<p class="wp-block-paragraph">You get two lines. <code>some</code> is the share of time at least one task was stalled waiting on I/O. <code>full</code> is the share of time <em>every</em> non-idle task was stalled at once, which is CPU capacity being burned for nothing. The <code>avg10</code>, <code>avg60</code> and <code>avg300</code> fields are percentages over the last 10, 60 and 300 seconds. <code>total</code> is a cumulative microsecond counter since boot, which is what you want for graphing because averages smooth away short spikes.</p>



<p class="wp-block-paragraph">The practical rule I use: a rising <code>full</code> line on <code>io</code> is a real user-visible problem almost every time. A high <code>some</code> line with a flat <code>full</code> line usually means one noisy job is waiting while everything else gets on with its life.</p>



<p class="wp-block-paragraph">If <code>/proc/pressure</code> doesn&#8217;t exist, the kernel was built without <code>CONFIG_PSI</code>, or it was built with PSI compiled in but disabled by default, in which case you need <code>psi=1</code> on the kernel command line. On cgroup v2 systems you also get per-cgroup pressure, which is how you narrow from &#8220;the host is stalling&#8221; to &#8220;this container is stalling&#8221; in one command:</p>



<pre class="wp-block-code"><code># Pressure for one systemd slice, useful on any modern distro
cat /sys/fs/cgroup/system.slice/io.pressure

# Pressure for every leaf cgroup that has it, sorted by nothing,
# just eyeball the full lines
find /sys/fs/cgroup -name io.pressure -exec sh -c 
  'printf "%s: " "$1"; sed -n 2p "$1"' _ {} ;</code></pre>



<p class="wp-block-paragraph">If you run Prometheus, node_exporter has exposed these since it gained a pressure collector. The counters are <code>node_pressure_io_waiting_seconds_total</code> for <code>some</code> and <code>node_pressure_io_stalled_seconds_total</code> for <code>full</code>. Rate those and you have a far better storage alert than any threshold on <code>%iowait</code> will ever be. Grafana Cloud and Netdata both chart PSI out of the box if you&#8217;d rather not build the panels yourself.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Failure family one: the device really is saturated</h2>



<p class="wp-block-paragraph">This is the case everyone assumes, so get it confirmed or eliminated quickly.</p>



<pre class="wp-block-code"><code>iostat -xy 1</code></pre>



<p class="wp-block-paragraph">The <code>-x</code> gives extended per-device stats. The <code>-y</code> matters more than people realise: without it, the first report is a since-boot average, and reading that first block is the single most common iostat mistake there is. It will tell you the disk has been fine all year, which is true and useless.</p>



<p class="wp-block-paragraph">Now the columns worth your attention:</p>



<ul class="wp-block-list">
<li><strong>r_await and w_await</strong>: average milliseconds a read or write took, queue time included. This is the closest thing to &#8220;how slow does storage feel&#8221;. Split reads from writes, because they fail very differently.</li>
<li><strong>aqu-sz</strong>: average queue depth. Requests either queued or in service. A depth that climbs while throughput stays flat is the signature of a genuinely saturated device.</li>
<li><strong>r/s, w/s, rkB/s, wkB/s</strong>: the actual workload. Compare against what the device or volume class is rated for.</li>
<li><strong>%util</strong>: treat with suspicion, see below.</li>
</ul>



<p class="wp-block-paragraph"><strong>The %util trap.</strong> That column counts the fraction of time at least one request was in flight. On a spinning disk that serviced one request at a time, it was a fair saturation signal. On NVMe and on cloud volumes that handle many requests in parallel, a device can sit at 100 percent while running at a fraction of its real capacity. Provisioning a bigger volume off the back of a 99 percent <code>%util</code> reading is a common and expensive way to change nothing. Use <code>aqu-sz</code> and the await columns instead. Similarly, <code>svctm</code> was deprecated and dropped from newer sysstat output because it was unreliable on parallel devices; ignore it if an old build still prints it.</p>



<p class="wp-block-paragraph">Once you know the device is busy, find the culprit. <code>pidstat -d 1</code> gives per-process read and write rates from the same sysstat package and needs nothing special enabled. <code>iotop -o</code> is friendlier, but on kernels from 5.14 onward per-task delay accounting is off by default, so the <code>SWAPIN</code> and <code>IO&gt;</code> columns come back empty until you turn it on:</p>



<pre class="wp-block-code"><code>sudo sysctl kernel.task_delayacct=1
sudo iotop -oPa
# turn it back off when you're done, it costs a little scheduler overhead
sudo sysctl kernel.task_delayacct=0</code></pre>



<p class="wp-block-paragraph">And check the boring things before the clever ones. <code>dmesg -T | grep -iE 'i/o error|reset|medium'</code> catches a failing drive or a flapping controller. <code>smartctl -a /dev/sda</code> catches reallocated sectors on hardware you own. If either of those turns something up, stop reading and replace the disk.</p>



<h2 class="wp-block-heading">Failure family two: memory pressure in an I/O costume</h2>



<p class="wp-block-paragraph">This is the failure family I&#8217;d check second, and it&#8217;s invisible if you only look at storage.</p>



<p class="wp-block-paragraph">When RAM gets tight, the kernel starts evicting page cache and swapping. Both generate disk traffic. The disk gets busy, iowait climbs, and every graph points at storage while the actual constraint is memory. Give the box more RAM and the &#8220;disk problem&#8221; disappears.</p>



<pre class="wp-block-code"><code># si and so are swap-in and swap-out in KB/s.
# Sustained non-zero values are your answer.
vmstat 1

# Are we thrashing writeback? Dirty pages waiting to be written.
grep -E 'Dirty|Writeback' /proc/meminfo

# The direct question
cat /proc/pressure/memory</code></pre>



<p class="wp-block-paragraph">If memory pressure and I/O pressure rise together, fix memory first. Everything else is downstream.</p>



<p class="wp-block-paragraph">There&#8217;s a related trap on the write side. The kernel buffers writes in page cache and flushes them in the background, governed by <code>vm.dirty_background_ratio</code> and <code>vm.dirty_ratio</code>. Cross the background threshold and flusher threads start writing. Cross the hard <code>vm.dirty_ratio</code> threshold and writing processes are forced to do writeback themselves, synchronously. That transition is brutal, and on a box with plenty of RAM and a modest disk the defaults let you accumulate gigabytes of dirty pages and then pay for all of them at once. Lowering the ratios makes writeback more frequent and less spiky, trading away a little write batching. Measure before you commit to it.</p>



<h2 class="wp-block-heading">Failure family three: small synchronous writes</h2>



<p class="wp-block-paragraph">Throughput can be trivial and the system still crawls. A workload doing thousands of tiny <code>fsync</code> calls a second is bound by round-trip latency, not bandwidth, and no amount of extra megabytes per second will help.</p>



<p class="wp-block-paragraph">Databases are the usual source, because durability has a cost and that cost is a flush. MySQL and MariaDB expose it through <code>innodb_flush_log_at_trx_commit</code>; PostgreSQL through <code>synchronous_commit</code>. Loosening either buys real throughput and trades away durability on power loss or crash. That can be a perfectly reasonable trade for a cache or an analytics replica, and a terrible one for anything holding orders or payments. Decide it explicitly, write down what you decided, and don&#8217;t let it be a thing somebody changed once at 2am.</p>



<p class="wp-block-paragraph">The other common source is logging. Application logs, access logs, container JSON logs and a database sharing one volume will starve each other. Splitting hot write paths onto separate devices is unglamorous and works.</p>



<h2 class="wp-block-heading">Failure family four: throttled, not saturated</h2>



<p class="wp-block-paragraph">A throttled device and a saturated device look almost identical from inside the guest: high await, growing queue, miserable latency. The difference is that no amount of tuning inside the box will fix a throttle. Three flavours are worth knowing.</p>



<ul class="wp-block-list">
<li><strong>Cloud volume limits.</strong> Managed block storage gives you a baseline of IOPS and throughput, sometimes with a burst allowance on top. Burn the burst and you drop to baseline, and everything that was fine in testing falls over in production. The tell is a latency cliff that arrives at a consistent point into a workload rather than tracking load smoothly. On AWS, watch the volume-level CloudWatch metrics such as <code>BurstBalance</code> on burstable volume types and <code>VolumeQueueLength</code>; other providers publish equivalents. The fix is a volume class change, not a sysctl.</li>
<li><strong>cgroup limits you set yourself.</strong> On cgroup v2, <code>io.max</code> caps a cgroup at a given bytes-per-second or IOPS figure, and <code>io.weight</code> shares bandwidth proportionally under contention. A limit that made sense when it was written can become the bottleneck a year later, and nothing in the container&#8217;s own metrics announces it.</li>
<li><strong>Neighbours on shared hardware.</strong> On oversubscribed VPS plans your storage is shared, and someone else&#8217;s backup job is your latency spike. This is where cheap and expensive hosting genuinely differ. Budget providers like Contabo and InterServer are fine value for a lot of workloads, but if storage latency is load-bearing for you, look for plans with dedicated NVMe rather than shared pools, and test before you migrate anything that matters.</li>
</ul>



<pre class="wp-block-code"><code># Per-device I/O accounting for a cgroup, including any throttling
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.stat

# Is a cap set? An empty file means no limit on that cgroup.
cat /sys/fs/cgroup/system.slice/docker-&lt;id&gt;.scope/io.max</code></pre>



<p class="wp-block-paragraph">One more signal while you&#8217;re here. In <code>top</code>, the <code>st</code> column is steal time, meaning the hypervisor gave your vCPU&#8217;s slice to someone else. Steal and iowait rising together on a shared host is a strong hint that the problem is the neighbourhood, not your configuration.</p>



<h2 class="wp-block-heading">Failure family five: the latency isn&#8217;t in the block layer</h2>



<p class="wp-block-paragraph">Here&#8217;s the failure mode that wastes the most time, because every storage metric you check will look healthy.</p>



<p class="wp-block-paragraph"><code>iostat</code> measures the block layer. It cannot see anything above it. So if your latency is in NFS, in a network filesystem, in an overlay or FUSE mount, in filesystem lock contention, or in a journal that&#8217;s serialising writes, the device will report sub-millisecond service times while your application waits tens of milliseconds per operation.</p>



<p class="wp-block-paragraph">The diagnosis is the gap. Measure latency at the application&#8217;s layer and at the block layer, and if the two numbers disagree wildly, the problem lives in between.</p>



<pre class="wp-block-code"><code># Block layer latency as a histogram. Look for a bimodal shape
# or a long tail rather than the average.
sudo biolatency-bpfcc -m 10 1      # Debian/Ubuntu naming
sudo /usr/share/bcc/tools/biolatency -m 10 1   # RHEL family

# Filesystem operations slower than 10ms, with the process and file
sudo ext4slower-bpfcc 10
sudo xfsslower-bpfcc 10

# Everything at the VFS layer, higher overhead, use briefly
sudo fileslower-bpfcc 10</code></pre>



<p class="wp-block-paragraph">If <code>ext4slower</code> is full of hundred-millisecond operations and <code>biolatency</code> shows everything completing in microseconds, you have your answer and it is not the disk. For NFS specifically, <code>nfsiostat</code> splits out RTT from execution time, which separates network cost from server cost. Install the tooling with <code>bpfcc-tools</code> on Debian and Ubuntu, or <code>bcc-tools</code> on RHEL, AlmaLinux and Rocky.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">A working order for troubleshooting high I/O wait</h2>



<p class="wp-block-paragraph">On an unfamiliar box, this is the sequence I work through. Each step either finds the problem or eliminates a family.</p>



<ol class="wp-block-list">
<li>Read <code>/proc/pressure/io</code>. If <code>full</code> is near zero, the stall is not hurting the whole system and you should widen the search before you tune storage.</li>
<li>Check <code>/proc/pressure/memory</code> at the same time. If it&#8217;s rising too, fix memory first.</li>
<li>Run <code>iostat -xy 1</code> for thirty seconds. Note <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> per device. Ignore <code>%util</code>.</li>
<li>List processes stuck in uninterruptible sleep: <code>ps -eo state,pid,comm | awk '$1 ~ /^D/'</code>. That&#8217;s your blocked set, and it names the workload.</li>
<li>Attribute the traffic with <code>pidstat -d 1</code>. Confirm the process you suspect is the process actually doing the I/O.</li>
<li>If await is high, decide saturated versus throttled. Check cgroup <code>io.max</code>, check provider volume metrics, check steal time.</li>
<li>If await is low but the application is slow, jump to the layer gap. Run <code>biolatency</code> and <code>ext4slower</code> together and compare.</li>
<li>Only now consider tuning: dirty ratios, I/O scheduler, flush settings, splitting volumes.</li>
</ol>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li><strong>Reading iostat&#8217;s first report.</strong> It&#8217;s a since-boot average. Use <code>-y</code>, or discard the first block.</li>
<li><strong>Trusting %util on NVMe or cloud volumes.</strong> Parallel devices break the assumption it was built on.</li>
<li><strong>Alerting on a fixed %iowait threshold.</strong> It fires on healthy backups and stays silent during real stalls on busy hosts.</li>
<li><strong>Changing the I/O scheduler first.</strong> Going from <code>mq-deadline</code> to <code>none</code> on NVMe is sometimes right, but it&#8217;s a tuning step, not a diagnosis, and it fixes nothing if you&#8217;re throttled.</li>
<li><strong>Adding swap to fix swapping.</strong> More swap on a memory-starved box means more disk traffic, not less.</li>
<li><strong>Stopping at the block layer.</strong> If every storage metric looks clean and the app is still slow, you&#8217;re measuring at the wrong layer, not measuring a healthy system.</li>
<li><strong>Testing with the wrong benchmark.</strong> A sequential <code>dd</code> tells you nothing about a workload doing random 4K synchronous writes. Use <code>fio</code> and match the real access pattern.</li>
</ul>



<h2 class="wp-block-heading">Best practices worth the effort</h2>



<ul class="wp-block-list">
<li><strong>Record PSI continuously.</strong> Storage incidents are short and bursty. Without history you&#8217;re guessing about something that already ended.</li>
<li><strong>Capture a baseline while things are healthy.</strong> Save an <code>iostat -xy</code> sample and a PSI reading from a normal day. &#8220;Is 8ms bad?&#8221; is unanswerable without one.</li>
<li><strong>Keep sar running.</strong> The sysstat package can archive metrics on a schedule, which means <code>sar -d</code> can show you what happened at 3am. Enable it before you need it.</li>
<li><strong>Separate hot write paths.</strong> Database data, database logs and application logs on separate volumes stops one from starving the others.</li>
<li><strong>Cap container logs.</strong> An uncapped JSON log driver quietly filling the root volume is a recurring cause of host-wide stalls.</li>
<li><strong>Alert on latency and pressure, not utilisation.</strong> Users feel <code>w_await</code> and PSI <code>full</code>. Nobody has ever felt a percentage of device busy time.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">What is a normal or acceptable I/O wait percentage?</h3>



<p class="wp-block-paragraph">There isn&#8217;t one, and any article that gives you a single number is guessing. Iowait scales with how idle your CPUs are, so the same workload produces very different figures on a busy server and a quiet one. Judge against your own baseline, and prefer PSI, which measures lost time directly rather than as a share of idle.</p>



<h3 class="wp-block-heading">Can high I/O wait be caused by something other than the disk?</h3>



<p class="wp-block-paragraph">Regularly. Memory pressure driving swap and page cache eviction is the most common non-disk cause. Network filesystems, hypervisor contention on shared hosts, and cgroup or cloud volume throttling all produce the same symptom. The block device being genuinely slow is one possibility among several.</p>



<h3 class="wp-block-heading">Why does iostat show 100% utilisation when the disk isn&#8217;t busy?</h3>



<p class="wp-block-paragraph">Because <code>%util</code> measures the fraction of time at least one request was in flight, which made sense for devices that handled one request at a time. NVMe drives and cloud volumes process many requests in parallel, so they can be continuously occupied while nowhere near capacity. Read <code>aqu-sz</code> and the await columns instead.</p>



<h3 class="wp-block-heading">How do I find which process is causing I/O wait?</h3>



<p class="wp-block-paragraph">Start with <code>pidstat -d 1</code>, which needs no special kernel settings. List processes in uninterruptible sleep with <code>ps -eo state,pid,comm</code> and filter for state D to see who is actually blocked. <code>iotop -o</code> gives a live view but needs <code>kernel.task_delayacct=1</code> on recent kernels for its percentage columns to populate.</p>



<h3 class="wp-block-heading">Does adding more RAM reduce I/O wait?</h3>



<p class="wp-block-paragraph">Often, yes, and it&#8217;s underrated. More RAM means a larger page cache, fewer reads reaching the device, and less swapping. If <code>vmstat</code> shows sustained swap activity or memory pressure tracks I/O pressure, RAM is likely the cheapest fix available.</p>



<h3 class="wp-block-heading">Should I change the I/O scheduler to fix high I/O wait?</h3>



<p class="wp-block-paragraph">Only after you know the device is the bottleneck. Check the current setting with <code>cat /sys/block/sda/queue/scheduler</code>. On NVMe, <code>none</code> is a common and sensible default because the hardware already does the queuing. On spinning disks, <code>mq-deadline</code> or <code>bfq</code> give better latency behaviour. It&#8217;s a tuning knob, not a diagnostic, and it will not help if you&#8217;re being throttled.</p>



<h3 class="wp-block-heading">Why is my application slow when iostat says storage is fine?</h3>



<p class="wp-block-paragraph">Because <code>iostat</code> only sees the block layer. Latency in NFS, FUSE, overlay filesystems, journal serialisation or filesystem locking is entirely invisible to it. Run <code>ext4slower</code> or <code>xfsslower</code> alongside <code>biolatency</code>: if the filesystem is slow and the device is fast, the gap between them is your problem.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">High I/O wait is a symptom that points in a general direction, not a diagnosis. It&#8217;s a slice of idle time, averaged across cores, measured at one specific layer of the stack. It goes up when nothing is wrong and stays flat when something is very wrong.</p>



<p class="wp-block-paragraph">Read PSI to find out whether anyone is actually suffering. Read <code>r_await</code>, <code>w_await</code> and <code>aqu-sz</code> to find out whether the device is the constraint. Compare filesystem latency against block latency to find out whether you&#8217;re even looking at the right layer. Do those three things and the answer usually falls out in a few minutes, instead of after a week of arguing with your hosting provider about a volume that was fine all along.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need someone to look at your storage stalls?</h2>



<p class="wp-block-paragraph">I work with teams on exactly this kind of problem, usually the ones that have already survived a round of &#8220;the host says the disk is fine&#8221;. Things I can help with:</p>



<ul class="wp-block-list">
<li>Diagnosing a live or recurring I/O stall and identifying which failure family it belongs to</li>
<li>Setting up PSI, sar and per-cgroup I/O metrics in Prometheus and Grafana, with alerts that fire on latency rather than utilisation</li>
<li>Separating cloud volume throttling from genuine device saturation, and sizing storage correctly instead of guessing upward</li>
<li>Tuning writeback, dirty ratios and I/O schedulers for a specific workload, with before-and-after measurements</li>
<li>Tracing container and Kubernetes I/O contention through cgroup v2 limits, overlay filesystems and log drivers</li>
<li>Benchmarking with <code>fio</code> against your real access pattern so capacity decisions rest on something other than a <code>dd</code> run</li>
</ul>



<p class="wp-block-paragraph">If you&#8217;ve got an <code>iostat</code> capture, a PSI reading or just a graph that doesn&#8217;t make sense, send it over and I&#8217;ll tell you what I see in it.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/troubleshooting-high-io-wait-linux/">Troubleshooting High I/O Wait Without Blaming the Disk</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out</title>
		<link>https://john-nessime.com/blog/devops/bootstrap-vps-with-ansible/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 13 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Ansible]]></category>
		<category><![CDATA[Ansible Collections]]></category>
		<category><![CDATA[Ansible Vault]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[cloud-init]]></category>
		<category><![CDATA[Fail2ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Idempotency]]></category>
		<category><![CDATA[Infrastructure as Code]]></category>
		<category><![CDATA[OpenSSH]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[Server Provisioning]]></category>
		<category><![CDATA[SSH]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[UFW]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=204</guid>

					<description><![CDATA[<p>A single Ansible run can take a blank VPS to a hardened, reproducible server. The hard part is not the tasks, it is the order. Here is the ordering that keeps you logged in, the failures that pass green and bite two weeks later, and how to make the second run as safe as the first.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/bootstrap-vps-with-ansible/">Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The playbook finishes green. Thirty-odd tasks, nothing failed, and the recap says the box is done. Two weeks later it reboots after a kernel update and nobody can get in on the port the playbook supposedly set.</p>



<p class="wp-block-paragraph">That is the real problem with trying to bootstrap a VPS with Ansible in a single run. The failures that cost you are not the ones that turn the recap red. They are the tasks that report <code>changed</code>, do nothing useful, and leave you believing the server is hardened when it is not.</p>



<p class="wp-block-paragraph">This post covers the ordering that keeps you connected while you dismantle your own access, the silent failure modes that only surface on reboot, and how to make the second run of the playbook as safe as the first. The examples are Debian and Ubuntu flavoured with notes for RHEL-family images, and they assume a plain provider VPS from somewhere like InterServer, Hetzner, DigitalOcean or Vultr, not a pre-baked golden image.</p>



<h2 class="wp-block-heading">What a one-run bootstrap actually has to survive</h2>



<p class="wp-block-paragraph">A bootstrap playbook is unusual because it modifies the thing it is standing on. Every other playbook you write assumes a working connection. This one has to change the user it connects as, the port it connects to, and the firewall that lets it connect at all, without dropping the run.</p>



<p class="wp-block-paragraph">Four things break it, and they break it in different ways:</p>



<ul class="wp-block-list">
<li><strong>The chicken-and-egg problem.</strong> Ansible needs Python on the target to run almost any module, and a minimal image may not have it.</li>

<li><strong>Lockout.</strong> You remove your own access before proving the replacement access works.</li>

<li><strong>Non-idempotent tasks.</strong> The first run is fine. The second run is where the damage happens.</li>

<li><strong>Missing collections.</strong> The playbook that worked on your laptop fails on the CI runner because half the modules do not ship with <code>ansible-core</code>.</li>
</ul>



<p class="wp-block-paragraph">Everything below is organised around those four, because that is how they actually show up.</p>



<h2 class="wp-block-heading">The chicken-and-egg problem: no Python, no modules</h2>



<p class="wp-block-paragraph">Most modern cloud images ship Python 3 and this never comes up. Minimal Debian netinstall images and some stripped provider templates do not, and then your very first task fails with a message about the interpreter before it has done anything at all.</p>



<p class="wp-block-paragraph">The escape hatch is <code>ansible.builtin.raw</code>, which pipes a command straight down the SSH connection without needing Python on the other end. Pair it with <code>gather_facts: false</code>, because fact gathering is itself a Python module and will fail before your bootstrap task ever runs.</p>



<pre class="wp-block-code"><code>- name: Make the host manageable
  hosts: new_servers
  gather_facts: false
  become: true
  tasks:
    - name: Check whether a Python interpreter exists
      ansible.builtin.raw: test -e /usr/bin/python3
      register: python_present
      changed_when: false
      failed_when: false

    - name: Install Python if it is missing
      ansible.builtin.raw: apt-get update &amp;&amp; apt-get install -y python3
      when: python_present.rc != 0
      changed_when: true

    - name: Gather facts now that we can
      ansible.builtin.setup:</code></pre>



<p class="wp-block-paragraph">Two details worth understanding rather than copying. <code>failed_when: false</code> on the check turns a non-zero exit into data instead of a failure, so you can branch on it. And <code>ansible.builtin.setup</code> is the fact-gathering module called explicitly, which means from that point on <code>ansible_distribution</code> and friends are available even though the play started with gathering disabled.</p>



<p class="wp-block-paragraph">If you are provisioning through a provider that supports user-data, doing this in cloud-init instead is cleaner. Let cloud-init handle first-boot truths, then let Ansible own everything that will change later.</p>



<h2 class="wp-block-heading">Failure family one: locking yourself out while the playbook says ok</h2>



<p class="wp-block-paragraph">This is the one that actually costs a rebuild, and it is entirely an ordering problem. The tasks are all correct in isolation. Run them in the wrong sequence and the playbook cheerfully removes your way back in before it has confirmed the replacement works.</p>



<h3 class="wp-block-heading">The order that survives</h3>



<p class="wp-block-paragraph">The rule is simple: <strong>nothing that removes access happens until something has proven the new access works.</strong> In practice:</p>



<ol class="wp-block-list">
<li>Connect as whatever the provider handed you, usually <code>root</code> or a sudo-capable default user.</li>

<li>Create the admin account, add it to the sudo or wheel group, install its authorized key.</li>

<li>Write the sudoers drop-in and validate it with <code>visudo -cf</code>.</li>

<li><strong>Prove you can log in as that account and escalate.</strong> This is the step everyone skips.</li>

<li>Install the firewall and allow SSH <em>before</em> enabling it.</li>

<li>Write the sshd configuration, validate it, restart, reconnect on the new port and confirm.</li>

<li>Only now disable root login and password authentication.</li>
</ol>



<p class="wp-block-paragraph">Step four is a real task, not a comment. It runs from the control node against the target, using the credentials you are about to depend on:</p>



<pre class="wp-block-code"><code>- name: Prove the new admin account can log in and escalate
  ansible.builtin.command: id -u
  become: true
  changed_when: false
  vars:
    ansible_user: "{{ admin_user }}"
    ansible_ssh_private_key_file: "{{ admin_key_path }}"</code></pre>



<p class="wp-block-paragraph">Setting connection variables at the task level means this single task uses the new identity while the rest of the play carries on as root. If the key is wrong or sudo is misconfigured, the play stops here with root login still intact, which is exactly what you want.</p>



<h3 class="wp-block-heading">The socket activation trap</h3>



<p class="wp-block-paragraph">This is the failure that passes green and bites on reboot. On Ubuntu images from 22.10 onward, sshd is started through systemd socket activation rather than as a persistent service. <code>ssh.socket</code> owns the listening port, and <code>Port</code> in <code>sshd_config</code> is not what decides where the daemon listens.</p>



<p class="wp-block-paragraph">So your playbook writes <code>Port 2222</code>, restarts the service, reports changed, and the box carries on listening on 22. Your existing session still works. Your firewall rule for 2222 looks fine. Nothing indicates a problem until something forces a reconnect.</p>



<p class="wp-block-paragraph">The fix is to write a socket override as well, and to check which mechanism the image actually uses rather than assuming:</p>



<pre class="wp-block-code"><code># Is the socket unit in play on this host?
systemctl is-enabled ssh.socket

# What is actually listening, and under which unit?
ss -tlnp | grep -E 'ssh|:22'</code></pre>



<p class="wp-block-paragraph">If <code>ssh.socket</code> is enabled, the override needs an empty <code>ListenStream=</code> first to clear the inherited default, then the real value. Miss the empty line and the host listens on both ports, which is worse than either outcome alone because your hardening looks applied and is not.</p>



<p class="wp-block-paragraph">Honestly, the simpler answer for most single-server setups is to leave SSH on 22 and lean on key-only authentication plus a firewall source restriction. Moving the port cuts log noise from opportunistic scanners. It does not stop anyone who is actually looking at you.</p>



<h3 class="wp-block-heading">Firewall ordering</h3>



<p class="wp-block-paragraph">UFW defaults to denying inbound traffic when you enable it. Enable first, add the SSH rule second, and the run dies mid-task with an <code>UNREACHABLE</code> that will not recover. Rules go in first, always:</p>



<pre class="wp-block-code"><code>- name: Allow SSH before the firewall is armed
  community.general.ufw:
    rule: allow
    port: "{{ ssh_port }}"
    proto: tcp

- name: Set default inbound policy
  community.general.ufw:
    direction: incoming
    policy: deny

- name: Enable UFW
  community.general.ufw:
    state: enabled</code></pre>



<p class="wp-block-paragraph">Two extra things bite here. Provider-level firewalls, the ones configured in the control panel rather than on the host, are a second layer that Ansible has no visibility into. If you open a port on the host and traffic still does not arrive, check there before debugging iptables. And if you moved the SSH port, the host firewall rule and the provider firewall rule both need updating, in that order, before the daemon restarts.</p>



<h2 class="wp-block-heading">Failure family two: the second run is the dangerous one</h2>



<p class="wp-block-paragraph">A bootstrap playbook you run once is a shell script with extra syntax. The value only appears when you can run it again on the same host, in six months, and have it either do nothing or fix drift. That is idempotency, and the usual killers are <code>shell</code> and <code>command</code> tasks with no guard.</p>



<p class="wp-block-paragraph">An unguarded <code>shell</code> task reports changed every single time, which means any handler it notifies fires every single time. That is how a playbook you thought was a no-op ends up restarting sshd on a production box during a routine drift check.</p>



<p class="wp-block-paragraph">Guard them properly:</p>



<ul class="wp-block-list">
<li><code>creates:</code> or <code>removes:</code> makes the task skip when the target state already exists. Prefer this.</li>

<li><code>changed_when: false</code> for read-only commands so they stop lying about changing things.</li>

<li><code>when:</code> driven by a registered check, for anything conditional.</li>
</ul>



<p class="wp-block-paragraph">The other idempotency trap is <code>lineinfile</code> against <code>sshd_config</code>. It works, and it is fragile in a specific way: the regex has to match the shipped file, and different distributions ship different defaults, commented or not. Writing a drop-in file is more predictable:</p>



<pre class="wp-block-code"><code>- name: Write the sshd hardening drop-in
  ansible.builtin.template:
    src: 99-hardening.conf.j2
    dest: /etc/ssh/sshd_config.d/99-hardening.conf
    owner: root
    group: root
    mode: "0600"
  notify: Restart sshd

- name: Validate the full sshd configuration
  ansible.builtin.command: sshd -t
  changed_when: false</code></pre>



<p class="wp-block-paragraph">Note that the validation runs against the whole configuration rather than the drop-in in isolation. A drop-in is not a valid standalone config file, so <code>validate:</code> on the template task with <code>sshd -t -f %s</code> will fail on a temporary file that has no host keys and no context. Run <code>sshd -t</code> as its own task afterwards instead, and let the handler restart only if it passes.</p>



<p class="wp-block-paragraph">Two prerequisites for the drop-in approach. The base <code>sshd_config</code> needs an <code>Include /etc/ssh/sshd_config.d/*.conf</code> line, which recent Debian, Ubuntu and RHEL 9 images have but older ones may not. And in sshd configuration the <em>first</em> value for a keyword wins, so the include has to appear near the top of the file for your drop-in to override anything below it. Check both before relying on the mechanism.</p>



<h2 class="wp-block-heading">Failure family three: the collections are not where you think</h2>



<p class="wp-block-paragraph"><code>ansible-core</code> ships the <code>ansible.builtin</code> namespace and essentially nothing else. The full <code>ansible</code> package bundles a large set of community collections, which is why a playbook that runs on your laptop can fail on a lean CI runner with a module-not-found error.</p>



<p class="wp-block-paragraph">Things people assume are built in and are not: <code>ufw</code> and <code>timezone</code> live in <code>community.general</code>. <code>authorized_key</code>, <code>sysctl</code> and <code>mount</code> live in <code>ansible.posix</code>. Declare them and pin them:</p>



<pre class="wp-block-code"><code># requirements.yml
collections:
  - name: community.general
  - name: ansible.posix

# install before the run, and in CI
ansible-galaxy collection install -r requirements.yml</code></pre>



<p class="wp-block-paragraph">Use fully qualified names everywhere. Short names still resolve, but they resolve through a lookup that can pick a different module when two collections define the same name. <code>ansible-lint</code> flags this and it is worth listening to.</p>



<h2 class="wp-block-heading">Failure family four: secrets in the repository</h2>



<p class="wp-block-paragraph">Bootstrap playbooks accumulate secrets faster than anything else you write: initial passwords, API tokens for monitoring agents, TLS material, backup credentials. They also tend to be the first repository someone shares with a client.</p>



<p class="wp-block-paragraph">Ansible Vault is the low-friction option and it is genuinely fine for a small estate. Encrypt the variable file, not the playbook, so diffs stay readable:</p>



<pre class="wp-block-code"><code>ansible-vault encrypt group_vars/all/vault.yml
ansible-playbook site.yml --ask-vault-pass</code></pre>



<p class="wp-block-paragraph">The honest trade-off: Vault gives you one shared passphrase with no rotation story and no audit trail. Once more than two people run the playbook, or a client needs their own credentials kept separate from yours, move to something with real access control. That is more moving parts, so do not reach for it on day one.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Putting it together: how to bootstrap a VPS with Ansible end to end</h2>



<p class="wp-block-paragraph">Structure matters less than order, but a shape that works well is a single <code>site.yml</code> calling roles in dependency order, with the risky access changes fenced off at the end.</p>



<pre class="wp-block-code"><code># inventory.yml
new_servers:
  hosts:
    web01:
      ansible_host: 203.0.113.10
      ansible_user: root
      ansible_port: 22
  vars:
    admin_user: deploy
    ssh_port: 22</code></pre>



<p class="wp-block-paragraph">Then the run itself, in this order:</p>



<ol class="wp-block-list">
<li><strong>Reachability.</strong> Python check, fact gathering, timezone, hostname, package cache refresh.</li>

<li><strong>Identity.</strong> Admin user, group membership, authorized key, sudoers drop-in with <code>visudo -cf</code> validation.</li>

<li><strong>Verification gate.</strong> The task shown earlier. Nothing destructive runs before this passes.</li>

<li><strong>Firewall.</strong> Rules first, policy second, enable third.</li>

<li><strong>Unattended updates.</strong> <code>unattended-upgrades</code> on Debian and Ubuntu, <code>dnf-automatic</code> with its systemd timer on RHEL-family hosts.</li>

<li><strong>Brute-force protection.</strong> fail2ban with your own <code>/etc/fail2ban/jail.local</code>, never edits to <code>jail.conf</code>.</li>

<li><strong>Workload.</strong> Docker, a web server, a monitoring agent, whatever the box is for.</li>

<li><strong>Access teardown.</strong> Disable root SSH login, disable password authentication, restart sshd, reconnect, confirm.</li>
</ol>



<p class="wp-block-paragraph">When step eight changes the port or user, the play needs to actually reconnect rather than reusing its cached SSH connection. Ansible keeps a persistent connection per host, so a config change alone will not be picked up:</p>



<pre class="wp-block-code"><code>- name: Point subsequent tasks at the new identity
  ansible.builtin.set_fact:
    ansible_user: "{{ admin_user }}"
    ansible_port: "{{ ssh_port }}"

- name: Drop the cached connection
  ansible.builtin.meta: reset_connection

- name: Confirm the host answers on the new settings
  ansible.builtin.wait_for_connection:
    timeout: 60</code></pre>



<p class="wp-block-paragraph"><code>meta: reset_connection</code> does not accept a <code>when</code> conditional, so it runs on every pass. That is a small cost for a play that works on the first run and every run after.</p>



<h2 class="wp-block-heading">Test it before you point it at anything real</h2>



<p class="wp-block-paragraph">The cheapest test is a disposable VPS. Spin one up, run the playbook, reboot it, run the playbook again, then destroy it. The reboot is the important part, because that is what exposes the socket activation problem and any service you enabled but never actually started.</p>



<pre class="wp-block-code"><code># catch YAML and structural errors instantly
ansible-playbook site.yml --syntax-check

# style and correctness, including FQCN and idempotency smells
ansible-lint

# show what would change without changing it
ansible-playbook -i inventory.yml site.yml --check --diff</code></pre>



<p class="wp-block-paragraph">One caveat on <code>--check</code>: it is not fully reliable on a bootstrap playbook. Tasks that depend on earlier tasks having run will fail or report nonsense in check mode, because the earlier change did not really happen. Treat a clean check run as a weak signal, not proof.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong><code>UNREACHABLE</code> on the very first task.</strong> Usually host key verification against a rebuilt host reusing an old IP. Remove the stale entry with <code>ssh-keygen -R</code> and re-add the correct one with <code>ssh-keyscan</code>. Disabling host key checking globally makes this go away and removes a real protection, so do not.</li>

<li><strong><code>UNREACHABLE</code> immediately after a firewall task.</strong> You armed the firewall before allowing SSH. Recover through the provider console, not SSH.</li>

<li><strong>&#8220;Missing sudo password&#8221;.</strong> The sudoers drop-in is not in effect or has bad syntax. Validate with <code>visudo -cf</code> on the file itself before trusting it.</li>

<li><strong>Module not found under an FQCN.</strong> The collection is not installed on the machine running the playbook. Confirm with <code>ansible-galaxy collection list</code>.</li>

<li><strong>New group membership ignored.</strong> Adding a user to <code>docker</code> mid-play does not affect the already-open connection. Insert <code>meta: reset_connection</code> after the group change.</li>

<li><strong>fail2ban installed but not banning.</strong> On images without rsyslog, <code>/var/log/auth.log</code> may not exist, so a jail configured to read it never starts. Check <code>fail2ban-client status</code> and switch the jail to the systemd backend if the log file is absent.</li>

<li><strong>Everything green, port unchanged.</strong> Socket activation. Check <code>systemctl is-enabled ssh.socket</code> and <code>ss -tlnp</code>.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Disabling root login in the same play that creates the replacement user, with no verification step between them.</li>

<li>Enabling UFW before the SSH allow rule exists.</li>

<li>Assuming <code>Port</code> in <code>sshd_config</code> is authoritative on every distribution.</li>

<li>Unguarded <code>shell</code> tasks that notify handlers, causing service restarts on every run.</li>

<li>Editing <code>jail.conf</code> instead of creating <code>jail.local</code>, so the next package update silently reverts it.</li>

<li>Committing plaintext secrets because &#8220;it is only a bootstrap playbook&#8221;.</li>

<li>Testing on a host you cannot afford to lose, with no console access configured.</li>

<li>Relying on <code>--check</code> as a substitute for a real run on a disposable host.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Know your recovery path before the first run. Provider console, serial console or recovery mode, tested once so you are not learning it under pressure.</li>

<li>Put a verification task before every irreversible one, and let the play fail there rather than after.</li>

<li>Prefer drop-in files over line edits for sshd, sudoers, fail2ban and sysctl. They survive package upgrades and are trivially removable.</li>

<li>Validate configuration before restarting the service that reads it. <code>sshd -t</code>, <code>visudo -cf</code>, <code>nginx -t</code>.</li>

<li>Pin collections in <code>requirements.yml</code> and install them in CI, not just locally.</li>

<li>Run the playbook twice on a fresh host and read the second recap. Anything still reporting changed is a bug.</li>

<li>Reboot once during testing. Half of the silent failures only appear after a restart.</li>

<li>Get a monitoring agent on the box in the same run. A server that is hardened but unobserved is a server you will hear about from a customer first.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can you really bootstrap a VPS with Ansible in a single playbook run?</h3>



<p class="wp-block-paragraph">Yes, including changing the SSH user and port mid-run. The mechanism is <code>set_fact</code> to update the connection variables, <code>meta: reset_connection</code> to drop the cached connection, and <code>wait_for_connection</code> to confirm the new settings work before continuing. What you cannot do safely is change access and skip the confirmation.</p>



<h3 class="wp-block-heading">Should I use cloud-init or Ansible for initial server setup?</h3>



<p class="wp-block-paragraph">Both, split by lifetime. cloud-init handles things that are true exactly once at first boot: the admin user, the SSH key, a minimal package set. Ansible handles everything that will be tuned again later. Putting hardening rules in cloud-init means the only way to change them is to rebuild the host.</p>



<h3 class="wp-block-heading">Do I need Terraform or OpenTofu as well?</h3>



<p class="wp-block-paragraph">Only if you are creating the infrastructure, not just configuring it. Terraform and OpenTofu create the VPS, the DNS records and the provider firewall. Ansible configures what runs inside. For a handful of long-lived servers ordered through a provider panel, Ansible alone is enough and adding a second tool costs more than it returns.</p>



<h3 class="wp-block-heading">Why does my playbook work the first time and fail the second?</h3>



<p class="wp-block-paragraph">Almost always because the first run changed the connection parameters and the inventory still describes the original ones. Either update the inventory to the post-bootstrap values and make the play tolerate both, or split bootstrap and steady-state into separate playbooks with separate inventory entries.</p>



<h3 class="wp-block-heading">Is changing the SSH port worth doing?</h3>



<p class="wp-block-paragraph">It reduces log noise from untargeted scanning, which makes real events easier to spot. It is not a security control on its own. Key-only authentication, a default-deny firewall and fail2ban do the actual work. If moving the port complicates your automation, the honest answer is to leave it on 22.</p>



<h3 class="wp-block-heading">How do I recover if the playbook locks me out?</h3>



<p class="wp-block-paragraph">Through the provider&#8217;s console or recovery environment, which is why you should know where it is before you need it. Most VPS providers offer a web-based console, some offer a rescue boot with the disk mounted. From there, revert the offending drop-in file or firewall rule, restart the service, and reconnect. Then add the verification step the playbook was missing.</p>



<h3 class="wp-block-heading">Can this run against several servers at once?</h3>



<p class="wp-block-paragraph">Yes, but use <code>serial: 1</code> the first few times. If there is an ordering bug in the play, running it against one host at a time means you lock yourself out of one server instead of a fleet.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">When you bootstrap a VPS with Ansible, the individual tasks are the easy part. Creating a user, writing a firewall rule and hardening sshd are all well-documented and hard to get wrong in isolation. What decides whether the run leaves you with a working server or a rebuild is the sequence, and specifically whether you verify new access before removing old access.</p>



<p class="wp-block-paragraph">Add the verification gate. Run the playbook twice. Reboot the box once. Those three habits catch nearly every silent failure described here, and they cost about ten minutes on a disposable host.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a bootstrap playbook you can actually rerun?</h2>



<p class="wp-block-paragraph">Most of the provisioning work I take on starts with a playbook that worked once and has been too frightening to run since. Things I help with:</p>



<ul class="wp-block-list">
<li>Building a bootstrap role that takes a blank VPS to a hardened, monitored server in one run, with a verification gate before every irreversible step.</li>

<li>Auditing an existing playbook for idempotency, so the second and hundredth runs are as safe as the first.</li>

<li>Untangling SSH, firewall and socket activation interactions on mixed Debian, Ubuntu and RHEL-family estates.</li>

<li>Splitting cloud-init and Ansible responsibilities cleanly so hardening stays changeable after first boot.</li>

<li>Moving secrets out of a repository into Vault or a managed secret store without breaking existing runs.</li>

<li>Wiring the playbook into CI with linting, collection pinning and a disposable-host test that runs on every change.</li>
</ul>



<p class="wp-block-paragraph">Send me the playbook, a recap from a second run, or the output of <code>ss -tlnp</code> from the host that is behaving oddly, and I will tell you what I would change first.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/bootstrap-vps-with-ansible/">Bootstrap a VPS With Ansible in One Run Without Locking Yourself Out</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>journald vs rsyslog: Where Your Linux Logs Actually Live</title>
		<link>https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 08 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[journalctl]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=168</guid>

					<description><![CDATA[<p>On a stock Linux server, journald and rsyslog are both running and both storing the same events, with retention policies that don't know about each other. Here's where each log line actually lives, the three failure modes that bite in production, and how to decide which copy you keep.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The disk alert usually comes first. <code>/var</code> sitting at 94 percent on a box that runs three services and logs almost nothing interesting. You go digging and find the same night&#8217;s SSH failures in two places: compressed binary files under <code>/var/log/journal/</code>, and plain text in <code>/var/log/secure</code>. Same events. Two copies. Nobody decided that. It is just what the distro shipped.</p>



<p class="wp-block-paragraph">The worse version of this problem is the mirror image. You reboot a server to clear something, come back to read what led up to it, and the journal starts at the reboot. Everything before is gone. Not rotated, not compressed, not archived somewhere clever. Gone, because the journal was living in RAM and nobody ever told it otherwise.</p>



<p class="wp-block-paragraph">Both of those are the same misunderstanding wearing different clothes: not knowing where your logs actually live. This post walks through the <strong>journald vs rsyslog</strong> split on a modern Linux server, what each one really stores, the three failure modes that bite in production, and how to decide which of the two is the copy you keep.</p>



<h2 class="wp-block-heading">What actually happens when a process logs a line</h2>



<p class="wp-block-paragraph">On any systemd-based distribution, <code>systemd-journald</code> is the first stop for almost everything. It is not one option among several. It sits underneath.</p>



<ul class="wp-block-list">
<li>Anything a unit writes to stdout or stderr, because systemd wires those to the journal by default.</li>

<li>Kernel messages, read from the kernel ring buffer.</li>

<li>Classic <code>syslog(3)</code> calls from libc, which land on the <code>/dev/log</code> socket that journald owns.</li>

<li>Native journal API calls from anything linked against libsystemd, which carry structured key-value fields instead of a flat string.</li>

<li>Audit records, if <code>Audit=yes</code> is in effect.</li>
</ul>



<p class="wp-block-paragraph">journald writes all of that into indexed binary journal files. Then, on most server distributions, rsyslog gets a <em>second copy</em> of the same events and writes them out as text into <code>/var/log/messages</code>, <code>/var/log/secure</code>, <code>/var/log/maillog</code> and friends.</p>



<p class="wp-block-paragraph">That handoff happens one of two ways, and which one your box uses matters more than most people expect. More on that below. First, the part that costs you an outage.</p>



<p class="wp-block-paragraph">Before changing anything, look at the effective journald configuration rather than the file you think is authoritative. Distributions ship drop-ins under <code>/usr/lib/systemd/journald.conf.d/</code> that quietly override upstream defaults:</p>



<pre class="wp-block-code"><code># Print the merged configuration, including every drop-in, in load order
systemd-analyze cat-config systemd/journald.conf</code></pre>



<p class="wp-block-paragraph">This is the single most useful command in this whole post. Half the arguments about &#8220;what the default is&#8221; evaporate once you run it, because upstream systemd and your distribution frequently disagree about <code>ForwardToSyslog</code>.</p>



<h2 class="wp-block-heading">Failure one: the journal that was never on disk</h2>



<p class="wp-block-paragraph">journald&#8217;s <code>Storage=</code> setting defaults to <code>auto</code>. That word does more work than it looks like. Under <code>auto</code>, journald writes to <code>/var/log/journal/</code> <em>only if that directory already exists</em>. If it does not, journald falls back to <code>/run/log/journal/</code>, which is tmpfs. Memory. Wiped on reboot.</p>



<p class="wp-block-paragraph">Nothing warns you. <code>journalctl</code> works fine, colours are pretty, filters work. You just silently have no history.</p>



<p class="wp-block-paragraph">The quickest test is boot history. If the journal is persistent you see multiple boots; if it is volatile you see exactly one:</p>



<pre class="wp-block-code"><code>journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">To make it persistent, create the directory, let systemd-tmpfiles apply the correct ownership and ACLs, then restart the daemon and flush anything still held in RAM:</p>



<pre class="wp-block-code"><code>sudo mkdir -p /var/log/journal
sudo systemd-tmpfiles --create --prefix /var/log/journal
sudo systemctl restart systemd-journald
sudo journalctl --flush
journalctl --list-boots</code></pre>



<p class="wp-block-paragraph">The <code>systemd-tmpfiles</code> step is the one people skip. The journal directory needs specific group ownership and ACLs so that unprivileged users can read their own entries and members of the <code>systemd-journal</code> group can read everything. Creating the directory with a bare <code>mkdir</code> and walking away usually works, but it is the kind of &#8220;usually&#8221; that produces a confusing permissions ticket six months later.</p>



<p class="wp-block-paragraph">If you want the behaviour to be explicit rather than inferred from a directory&#8217;s existence, set it in <code>/etc/systemd/journald.conf.d/00-storage.conf</code>:</p>



<pre class="wp-block-code"><code>[Journal]
Storage=persistent</code></pre>



<p class="wp-block-paragraph">A drop-in file is better than editing the main config, because package upgrades will not fight you over it.</p>



<h2 class="wp-block-heading">Failure two: paying for the same log line twice</h2>



<p class="wp-block-paragraph">Once the journal is persistent, you are storing everything twice on a default server build: once as compressed binary in the journal, once as text under <code>/var/log/</code>. Two stores, two completely separate retention policies, neither of which knows the other exists.</p>



<p class="wp-block-paragraph">The journal is capped by <code>SystemMaxUse=</code> and <code>SystemKeepFree=</code>. Left unset, they default to 10 percent and 15 percent of the filesystem respectively, and journald honours whichever is stricter. Recent systemd also caps those calculated defaults so they do not grow without bound on a large disk, which is worth knowing if you have ever wondered why a huge volume did not produce a proportionally huge journal.</p>



<p class="wp-block-paragraph">The text copy is capped by <code>logrotate</code>, configured somewhere completely different, usually <code>/etc/logrotate.d/rsyslog</code>, on a weekly or daily schedule with its own <code>rotate</code> count.</p>



<p class="wp-block-paragraph">Measure both before you tune either:</p>



<pre class="wp-block-code"><code># What the journal is actually consuming on disk
journalctl --disk-usage

# What everything under /var/log costs, sorted, biggest last
sudo du -sh /var/log/* | sort -h</code></pre>



<p class="wp-block-paragraph">On a small VPS this stops being academic quickly. A modest instance from a provider like InterServer, Hetzner or DigitalOcean often has <code>/var</code> sharing a single root volume of 20 to 40 GB. A journal allowed to take 10 percent of that, plus four weeks of rotated text logs, plus a database, plus a container image cache, is how you end up paged at two in the morning for something that is not a real incident.</p>



<p class="wp-block-paragraph">If you decide the text files are the copy you keep, put the journal on a short leash:</p>



<pre class="wp-block-code"><code>[Journal]
# Hard ceiling on total journal size
SystemMaxUse=200M
# Never let the journal push free space below this
SystemKeepFree=1G
# Discard entries older than this regardless of size
MaxRetentionSec=2day</code></pre>



<p class="wp-block-paragraph">Those three do different jobs and you generally want at least two of them. <code>SystemMaxUse</code> bounds the journal itself. <code>SystemKeepFree</code> protects the rest of the filesystem from the journal. <code>MaxRetentionSec</code> bounds it in time, which is what auditors and data-retention policies actually care about.</p>



<p class="wp-block-paragraph">To reclaim space immediately without waiting for the next rotation:</p>



<pre class="wp-block-code"><code># Trim archived journal files until the total falls below 500M
sudo journalctl --vacuum-size=500M

# Or trim by age instead
sudo journalctl --vacuum-time=7d</code></pre>



<p class="wp-block-paragraph">One caveat that surprises people: vacuuming only removes <em>archived</em> journal files. The currently active file is never deleted. If almost all your usage is in one large active file, run <code>journalctl --rotate</code> first, then vacuum.</p>



<h2 class="wp-block-heading">Failure three: two rate limiters, both silent</h2>



<p class="wp-block-paragraph">This is the one that is invisible until it matters, and it is the real reason to understand the journald vs rsyslog relationship rather than treating them as interchangeable.</p>



<p class="wp-block-paragraph">There are <em>two independent rate limiters</em> in the default pipeline, and both drop messages quietly.</p>



<h3 class="wp-block-heading">journald&#8217;s limiter</h3>



<p class="wp-block-paragraph">journald applies <code>RateLimitIntervalSec=</code> and <code>RateLimitBurst=</code> per service, defaulting to 30 seconds and 10000 messages. Exceed the burst inside the interval and the rest of that service&#8217;s messages in that window are discarded. Not queued. Discarded.</p>



<p class="wp-block-paragraph">The one piece of good news is that journald tells you, in the journal itself. Grep for it:</p>



<pre class="wp-block-code"><code>journalctl --grep="Suppressed" -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">A chatty web server or a container using the journald log driver will hit this. Rather than disabling rate limiting globally, override it for the one unit that needs it, using a drop-in:</p>



<pre class="wp-block-code"><code># /etc/systemd/system/nginx.service.d/logging.conf
[Service]
LogRateLimitIntervalSec=30s
LogRateLimitBurst=50000</code></pre>



<p class="wp-block-paragraph">Per-unit values override <code>journald.conf</code> for that unit only, which is exactly what you want. Turning the global limiter off means one runaway process can fill your disk and take the box down. Turning it up for the single service you know is noisy is a bounded decision.</p>



<h3 class="wp-block-heading">rsyslog&#8217;s limiter</h3>



<p class="wp-block-paragraph">If rsyslog is reading via the <code>imjournal</code> module, it applies <em>its own</em> rate limit on top, defaulting to 20000 messages per 600 seconds. A message can therefore survive journald&#8217;s limiter, land in the journal, and still never reach <code>/var/log/messages</code> or your remote log host.</p>



<p class="wp-block-paragraph">That is the nasty case, because your local investigation finds the events but your centralised alerting never fired. Two people look at two sources and reach opposite conclusions about whether something happened.</p>



<p class="wp-block-paragraph">rsyslog exposes counters for this through its statistics module, including how many messages it read from the journal, how many it submitted onward, and how many it discarded to rate limiting. Enable <code>impstat</code> if you run anything log-volume-sensitive; the discard counter is the number that tells you whether your pipeline is lying to you.</p>



<h2 class="wp-block-heading">How rsyslog gets its copy: imjournal or imuxsock</h2>



<p class="wp-block-paragraph">There are two mechanisms, they behave differently under load, and running both at once is a classic source of duplicate lines.</p>



<h3 class="wp-block-heading">imuxsock: journald pushes a copy to a socket</h3>



<p class="wp-block-paragraph">With <code>ForwardToSyslog=yes</code> in journald, journald writes a classic syslog-formatted copy of each message to a dedicated socket, and rsyslog reads it with <code>imuxsock</code>.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> simple, no state file, no second rate limiter, and no possibility of rsyslog reading back its own output and looping.</li>

<li><strong>Where it does not:</strong> you get the flat syslog view only. Structured journal fields such as the originating unit are not carried across.</li>
</ul>



<h3 class="wp-block-heading">imjournal: rsyslog pulls from the journal</h3>



<p class="wp-block-paragraph"><code>imjournal</code> reads journal files directly and keeps a state file so it can resume where it left off after a restart.</p>



<ul class="wp-block-list">
<li><strong>Where it wins:</strong> structured fields survive the trip. If you want to route or template on the systemd unit name rather than parsing it out of a message string, this is the only option of the two.</li>

<li><strong>Where it does not:</strong> extra rate limiter to reason about, a state file that can drift, and a documented risk that a corrupted journal database causes rsyslog to re-read the same entries in a loop. rsyslog&#8217;s own documentation recommends using <code>imuxsock</code> instead unless you specifically need the structured data.</li>
</ul>



<p class="wp-block-paragraph">A representative <code>imjournal</code> load line, with the rate limit stated explicitly rather than left implicit:</p>



<pre class="wp-block-code"><code>module(load="imjournal"
       StateFile="imjournal.state"
       Ratelimit.Interval="600"
       Ratelimit.Burst="20000")</code></pre>



<p class="wp-block-paragraph">Writing the defaults out by hand is worth the two extra lines. The next person to debug missing messages will see the limiter exists instead of assuming there isn&#8217;t one.</p>



<p class="wp-block-paragraph">To find out which mechanism your box is using right now:</p>



<pre class="wp-block-code"><code>grep -R "imjournal|imuxsock" /etc/rsyslog.conf /etc/rsyslog.d/ 2&gt;/dev/null
grep -R "ForwardToSyslog" /etc/systemd/journald.conf /etc/systemd/journald.conf.d/ /usr/lib/systemd/journald.conf.d/ 2&gt;/dev/null</code></pre>



<p class="wp-block-paragraph">If the first command returns both modules and the second returns <code>ForwardToSyslog=yes</code>, you have a duplication problem. Pick one path and disable the other.</p>



<h2 class="wp-block-heading">Only one of the two leaves the box</h2>



<p class="wp-block-paragraph">This is the argument that settles most journald vs rsyslog debates in practice. journald is a local store. In a stock server install it has no mechanism for shipping logs to another host. There are separate systemd components for journal upload and reception, and third-party projects that read the journal and forward it, but none of that is in the default path.</p>



<p class="wp-block-paragraph">rsyslog forwards as a first-class feature, and it does the part that actually matters: it buffers when the destination is unreachable.</p>



<pre class="wp-block-code"><code>global(workDirectory="/var/spool/rsyslog")

action(type="omfwd"
       target="logs.example.net"
       port="514"
       protocol="tcp"
       action.resumeRetryCount="-1"
       queue.type="LinkedList"
       queue.filename="fwd_loghost"
       queue.maxDiskSpace="1g"
       queue.saveOnShutdown="on")</code></pre>



<p class="wp-block-paragraph">Line by line, because these are not decoration:</p>



<ul class="wp-block-list">
<li><code>workDirectory</code> is where spool files get written. Without it, the disk queue has nowhere to go.</li>

<li><code>protocol="tcp"</code> rather than UDP. UDP syslog silently drops under congestion, which defeats the point of forwarding at all.</li>

<li><code>queue.type="LinkedList"</code> makes the action asynchronous so a slow destination does not block local processing.</li>

<li><code>queue.filename</code> is what actually enables disk assistance. In-memory queue first, spilling to disk when it fills. The name must be unique across every action in the config.</li>

<li><code>queue.maxDiskSpace</code> caps that spool. Set it, or a long outage at the far end fills the disk you were trying to protect.</li>

<li><code>queue.saveOnShutdown="on"</code> persists whatever is still queued across a service restart.</li>
</ul>



<p class="wp-block-paragraph">One honest caveat on <code>action.resumeRetryCount="-1"</code>. Infinite retry is the right default for &#8220;never lose a log line&#8221;, but there are reported cases where a relay under sustained back-pressure stops accepting new inbound messages while retrying forever. If your host is a relay rather than a leaf, test that behaviour deliberately with the destination firewalled off before you trust it.</p>



<p class="wp-block-paragraph">rsyslog is not the only way off the box. Grafana Alloy into Loki, Vector, and Fluent Bit all read the journal directly and speak modern backends, and hosted platforms such as Better Stack, Papertrail or Datadog will take a plain syslog stream if you would rather not run the storage side. The mechanism differs; the decision does not. Something has to own the write path off the machine, and journald on its own is not it.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">journald vs rsyslog: how I would decide</h2>



<p class="wp-block-paragraph">Stop asking which is better. Ask which one is the copy you are willing to be judged on, then make the other one cheap.</p>



<ol class="wp-block-list">
<li><strong>Do logs need to leave this machine?</strong> If yes, rsyslog or a dedicated shipper owns the outbound path. That is settled before anything else.</li>

<li><strong>Do you troubleshoot mostly by unit?</strong> If <code>journalctl -u something -b</code> is your muscle memory, journald is your primary read path and should get the retention budget.</li>

<li><strong>Do existing tools read text files?</strong> Fail2ban, logwatch, older SIEM collectors and a lot of home-grown scripts parse <code>/var/log/*</code>. Turning rsyslog off breaks them quietly.</li>

<li><strong>How much disk do you actually have?</strong> Under about 40 GB, keeping two full copies is an unforced error. Cap one hard.</li>

<li><strong>Is tamper-evidence a requirement?</strong> journald supports Forward Secure Sealing, which detects after-the-fact modification of journal files. Shipping to an append-only remote store is the more commonly accepted answer, and the two are not mutually exclusive.</li>
</ol>



<p class="wp-block-paragraph">The configuration I reach for first on a general-purpose server: journald persistent with a hard cap for a few days of fast local querying, rsyslog kept for the syslog files that other tools expect plus reliable forwarding off-box, <code>imuxsock</code> as the handoff unless something genuinely needs structured fields, and logrotate retention trimmed down because the remote copy is the one that matters for anything older than a week.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<ul class="wp-block-list">
<li><strong>Journal only shows the current boot.</strong> Volatile storage. <code>journalctl --list-boots</code> confirms it, and the fix is the persistent-storage sequence above.</li>

<li><strong>Events in journalctl but not in /var/log/messages.</strong> Either rsyslog is not reading from the journal at all, or the <code>imjournal</code> rate limiter is discarding. Check which input module is loaded first.</li>

<li><strong>Every line appears twice in the text logs.</strong> Both <code>imjournal</code> and <code>imuxsock</code> are active while <code>ForwardToSyslog=yes</code>. Disable one path.</li>

<li><strong>Bursts of messages disappear during incidents.</strong> That is a rate limiter, and incidents are exactly when services get loud. Search for suppression notices and raise the limit for that specific unit.</li>

<li><strong>journalctl reports corruption or behaves oddly.</strong> Run <code>journalctl --verify</code>. If files are damaged, <code>journalctl --rotate</code> starts a fresh active file so new writes are clean, then vacuum the bad archives.</li>

<li><strong>&#8220;Journal has been rotated since unit was started.&#8221;</strong> Not an error. Rotation happened mid-session, so <code>journalctl -u</code> cannot map the full range. Re-run the query without the unit filter or widen the time range.</li>

<li><strong>Disk still full after vacuuming.</strong> Vacuum skips the active journal file. Rotate first, then vacuum again.</li>
</ul>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Assuming the journal is persistent because <code>journalctl</code> returns results. It always returns results. The question is how far back.</li>

<li>Disabling rsyslog on a box where fail2ban, logwatch or a scraper still reads <code>/var/log/secure</code>. Nothing errors. Detection just stops.</li>

<li>Setting <code>RateLimitBurst=0</code> globally to &#8220;stop losing logs&#8221;, then having a crash-looping service fill the disk in an afternoon.</li>

<li>Editing <code>/etc/systemd/journald.conf</code> directly and being surprised when a vendor drop-in overrides it. Use a drop-in of your own with a name that sorts later.</li>

<li>Forwarding over UDP because it is the one-line version. It drops silently under exactly the load that produced the logs you wanted.</li>

<li>Enabling a disk-assisted queue without <code>queue.maxDiskSpace</code>, turning a remote outage into a local disk-full outage.</li>

<li>Tuning journald limits on a machine whose real problem is that one application logs every health check at info level. Fix the source first.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Make storage explicit. <code>Storage=persistent</code> or <code>Storage=volatile</code>, never left to <code>auto</code> on a server you care about.</li>

<li>Always set <code>SystemKeepFree</code> alongside <code>SystemMaxUse</code>. The first bounds the journal, the second protects everything else on the volume from it.</li>

<li>Decide consciously which store is authoritative, and write it in the runbook. Two stores with no stated owner means neither gets maintained.</li>

<li>Use exactly one journal-to-rsyslog path. Both is duplication; neither is silent data loss.</li>

<li>Override rate limits per unit, not globally.</li>

<li>Monitor <code>journalctl --disk-usage</code> as a metric, not as something you check after the alert.</li>

<li>Ship off-box with TCP and a bounded disk-assisted queue, and test it with the destination firewalled off before you rely on it.</li>

<li>Keep the local copy short and the remote copy long. Local disk is the expensive place to store history.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can I just disable rsyslog and use journald only?</h3>



<p class="wp-block-paragraph">On a self-contained box with no remote logging and nothing parsing text files, yes, and it saves real disk. Before you do it, grep your configuration management and cron jobs for <code>/var/log/</code> paths, and check whether fail2ban or any monitoring agent reads those files. That is where this bites people.</p>



<h3 class="wp-block-heading">Can I disable journald and use rsyslog only?</h3>



<p class="wp-block-paragraph">Not meaningfully. journald is how systemd captures unit stdout and stderr, so it stays in the path regardless. What you can do is set <code>Storage=volatile</code> with a small <code>RuntimeMaxUse</code> so it holds a short in-memory window and rsyslog owns everything durable.</p>



<h3 class="wp-block-heading">Where are journald logs stored?</h3>



<p class="wp-block-paragraph"><code>/var/log/journal/</code> when persistent, <code>/run/log/journal/</code> when volatile. They are indexed binary files, not text, so <code>grep</code> and <code>tail</code> do not work on them directly. Use <code>journalctl</code>, or <code>journalctl -o json</code> if you want to pipe structured output into something else.</p>



<h3 class="wp-block-heading">Why does journalctl show entries that never reached /var/log/messages?</h3>



<p class="wp-block-paragraph">Three usual causes: rsyslog is not reading the journal at all, the <code>imjournal</code> rate limiter dropped them, or a severity filter such as <code>MaxLevelSyslog</code> or an rsyslog rule excluded that facility or priority. Check in that order.</p>



<h3 class="wp-block-heading">Is the binary journal format a problem for log analysis?</h3>



<p class="wp-block-paragraph">Locally, no. Structured fields and fast filtering are genuinely better than parsing text with regex. It becomes a problem when you need to move logs somewhere else, because almost every collector expects text or JSON. That conversion step is precisely the role rsyslog or a modern shipper plays.</p>



<h3 class="wp-block-heading">How much disk should the journal be allowed to use?</h3>



<p class="wp-block-paragraph">Work backwards from how far back you actually query locally. If you rarely look past yesterday, a few hundred megabytes with <code>MaxRetentionSec</code> set to two or three days is plenty. Anything older belongs in a remote store where it is cheaper and survives the machine.</p>



<h3 class="wp-block-heading">Does journald compress logs automatically?</h3>



<p class="wp-block-paragraph">Yes. <code>Compress=</code> defaults to enabled and applies to entries above a size threshold, which is one reason the journal is often smaller than the equivalent text files despite carrying more metadata. It does not remove the need for a size cap.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">The journald vs rsyslog question is not a contest. On a stock server both are running, both are storing the same events, and the defaults were chosen by your distribution rather than by you. The failure modes all come from that: a journal in RAM that vanishes on reboot, two uncoordinated retention policies eating the same disk, and two rate limiters dropping messages without anyone noticing.</p>



<p class="wp-block-paragraph">Run <code>systemd-analyze cat-config systemd/journald.conf</code> and <code>journalctl --list-boots</code> on your servers. Two commands, thirty seconds, and you will know whether the logs you would reach for during an incident are actually there.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need help sorting out your logging pipeline?</h2>



<p class="wp-block-paragraph">Logging is one of those areas where the defaults work well enough to hide the problem until an incident makes it expensive. If any of the above sounded familiar, these are the things I take on:</p>



<ul class="wp-block-list">
<li>Auditing journald and rsyslog on existing servers and telling you exactly where each log line lives, how long it survives, and what is being silently dropped.</li>

<li>Fixing volatile journals, runaway <code>/var</code> growth and duplicate text-and-binary storage on small VPS instances.</li>

<li>Setting up reliable off-box forwarding with disk-assisted queues, TCP or RELP transport, and TLS where it is required.</li>

<li>Migrating from text-file scraping to a structured pipeline into Loki, Elasticsearch or a hosted log platform, without losing the alerts you already depend on.</li>

<li>Tuning rate limits and retention per service so noisy applications stop hiding the messages that matter.</li>

<li>Writing the runbook that says which store is authoritative, so the next engineer does not have to reverse-engineer it during an outage.</li>
</ul>



<p class="wp-block-paragraph">Send me your <code>journald.conf</code>, your <code>rsyslog.conf</code>, or the output of <code>journalctl --disk-usage</code> and I will tell you what I see before we talk about scope.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/journald-vs-rsyslog-where-logs-live/">journald vs rsyslog: Where Your Linux Logs Actually Live</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Podman vs Docker: Is the Switch Actually Worth It?</title>
		<link>https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 07 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Architecture]]></category>
		<category><![CDATA[Cloud Security]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Podman]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Quadlet]]></category>
		<category><![CDATA[Rootless Containers]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=162</guid>

					<description><![CDATA[<p>Podman is the better-designed container engine and Docker is the better-supported one. A working engineer's comparison: the architectural difference that drives everything, where each one wins and loses, the four things that genuinely break in a migration (compose, the Docker socket, privileged ports, restart on boot), and a decision procedure to run against your own setup.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The question usually arrives from outside the engineering team. Someone doing an access review notices that half the developers are in the <code>docker</code> group on the build hosts, asks what that grants them, and gets an answer they do not like. Or procurement forwards a note about Docker Desktop licensing and wants to know whether there is a free option.</p>



<p class="wp-block-paragraph">Either way you end up looking at Podman, reading a Podman vs Docker comparison table that says &#8220;daemonless&#8221; and &#8220;rootless by default&#8221;, and trying to work out whether those words are worth a migration.</p>



<p class="wp-block-paragraph">Here is the short version, and I will spend the rest of the post defending it. The architectural difference between the two engines is real and it matters more than the feature tables suggest. The migration cost is also real, and it is almost entirely concentrated in four places that comparison posts tend to skip. Whether the switch is worth it depends on which of those four you actually depend on.</p>



<p class="wp-block-paragraph">This post covers the one architectural difference everything else follows from, an honest profile of each engine with where it wins and where it does not, the four things that genuinely break when you switch, and a decision procedure you can run against your own setup rather than against a generic one.</p>



<h2 class="wp-block-heading">The difference everything else follows from</h2>



<p class="wp-block-paragraph">Docker is a client and a daemon. The <code>docker</code> CLI is a thin thing that talks to <code>dockerd</code>, which runs as root and owns every container on the host. Podman has no daemon. When you run <code>podman run</code>, the container is started as a child of your own process and supervised by a small monitor, not handed off to a privileged background service.</p>



<p class="wp-block-paragraph">Two consequences follow, and they are the whole argument.</p>



<p class="wp-block-paragraph"><strong>Membership of the <code>docker</code> group is equivalent to root.</strong> Not &#8220;close to root&#8221;, not &#8220;root if misconfigured&#8221;. The daemon runs as root and will happily create a container that bind-mounts the host&#8217;s root filesystem, so anyone who can talk to the socket can read or write anything on the box. Docker&#8217;s own documentation says as much. This is the finding that shows up in access reviews, and it is not a bug you can configure away without switching to rootless mode.</p>



<p class="wp-block-paragraph"><strong>Process supervision belongs to whoever started the container.</strong> With Docker, the daemon restarts your containers according to the restart policy, and if the daemon wedges you have a bad afternoon regardless of what the containers are doing. With Podman, containers are ordinary processes, so systemd supervises them the same way it supervises everything else on the machine, with the same logging, dependency ordering and failure handling.</p>



<p class="wp-block-paragraph">Podman&#8217;s rootless mode builds on user namespaces: UID 0 inside the container maps to your unprivileged UID on the host. A break-out lands in a normal user account rather than on a root shell. Docker has a rootless mode too and it works, but it is an opt-in you configure rather than the path of least resistance, which in practice means far fewer installations run it.</p>



<h2 class="wp-block-heading">Docker: where it wins and where it doesn&#8217;t</h2>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>The ecosystem assumes it.</strong> Testcontainers, VS Code Dev Containers, Traefik&#8217;s container discovery, Portainer, most CI runners, half the SDKs on your machine: all of them expect a Docker socket. Some work fine against Podman&#8217;s compatible socket. Some do not, and finding out which is your problem.</li>

<li><strong>Compose is first-party.</strong> Docker Compose is maintained by the same people who maintain the engine, tracks the Compose specification closely, and gets features like <code>--wait</code> and profiles as soon as they exist. Nothing in the Podman world matches that.</li>

<li><strong>Every answer to every question already exists.</strong> Fifteen years of Stack Overflow. That is not a technical merit but it is a real operational one at 2am.</li>

<li><strong>Cross-platform experience is smoother.</strong> Docker Desktop on macOS and Windows is a polished product, and the VM it manages is largely invisible.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>The root daemon is a standing liability.</strong> One privileged process that everything flows through, plus a group that is root by another name.</li>

<li><strong>Docker Desktop is licensed, and the terms are specific.</strong> Docker&#8217;s own license page states Desktop is free for personal use, education, non-commercial open source, and small businesses defined as fewer than 250 employees <em>and</em> less than ten million dollars in annual revenue. Cross either threshold and commercial use needs a paid subscription. Government entities need one regardless. Docker Engine and the Moby project are not affected. This catches people out because the software installs without asking for a key.</li>

<li><strong>systemd integration is bolted on.</strong> You can wrap a Compose stack in a unit file, but systemd is supervising a wrapper, not the containers.</li>

<li><strong>Restarting the daemon touches everything.</strong> An engine upgrade is a whole-host event rather than a per-service one.</li>
</ul>



<h2 class="wp-block-heading">Podman: where it wins and where it doesn&#8217;t</h2>



<h3 class="wp-block-heading">Where it wins</h3>



<ul class="wp-block-list">
<li><strong>Rootless is the default, not a project.</strong> You get the safer posture by doing nothing special, which is the only kind of security control that survives contact with a busy team.</li>

<li><strong>Quadlet is genuinely better than restart policies.</strong> You write a declarative unit file, systemd generates a real service from it, and container lifecycle becomes indistinguishable from any other service on the box. This is the feature I would actually miss if I went back.</li>

<li><strong>No licensing question to answer.</strong> Podman is Apache 2.0 with no commercial-use restriction, and on Red Hat Enterprise Linux, Fedora, Rocky and AlmaLinux it is the packaged, supported option already sitting in the repos.</li>

<li><strong>Pods and Kubernetes YAML are first-class.</strong> <code>podman kube play</code> runs a Kubernetes manifest locally, and <code>podman generate kube</code> goes the other way. If your destination is Kubernetes, the mental model transfers.</li>

<li><strong>Per-user isolation comes free.</strong> Two developers on a shared build host cannot see or stop each other&#8217;s containers, because they are running in separate user namespaces rather than as siblings under one daemon.</li>
</ul>



<h3 class="wp-block-heading">Where it doesn&#8217;t</h3>



<ul class="wp-block-list">
<li><strong>Compose is a compatibility story, not a native one.</strong> More on this below, because it is the single biggest source of migration pain.</li>

<li><strong>Rootless networking has sharp edges.</strong> Traffic goes through a userspace network stack, and the behaviour is not identical to a root bridge. Source IPs, host-loopback access and privileged ports all behave differently, and you will meet at least one of those.</li>

<li><strong>Swarm and Docker plugins do not exist.</strong> Podman does not implement Swarm mode, and Docker volume and network plugins are not supported. If you use either, the conversation is over.</li>

<li><strong>Desktop platforms mean a VM you can see.</strong> Podman on macOS and Windows runs a Podman machine, and the seams show more than Docker Desktop&#8217;s do.</li>

<li><strong>Documentation assumes Docker.</strong> Every vendor quickstart, every README, every &#8220;run this to try it&#8221; snippet is written for <code>docker run</code>. Most translate directly. The ones that do not cost you an hour each.</li>
</ul>



<h2 class="wp-block-heading">The four things that actually break</h2>



<p class="wp-block-paragraph">This is the part worth reading twice. Command compatibility is not the problem; <code>podman</code> accepts almost every <code>docker</code> flag you know. The problems are elsewhere.</p>



<h3 class="wp-block-heading">1. Compose</h3>



<p class="wp-block-paragraph">There are two routes and they are not equivalent.</p>



<p class="wp-block-paragraph"><strong>podman-compose</strong> is a separate Python implementation. It covers the common cases and diverges on the uncommon ones. Container naming is the one that bites scripts first: it has historically used underscores where Docker Compose v2 uses hyphens, so anything referencing <code>project-web-1</code> by name stops finding it.</p>



<p class="wp-block-paragraph"><strong>The Podman socket plus real Docker Compose</strong> is the route I would take. Podman exposes a Docker-compatible REST API over a systemd socket, and the actual Compose binary talks to it:</p>



<pre class="wp-block-code"><code># Start the Docker-compatible API socket for your user.
systemctl --user enable --now podman.socket

# Confirm it exists before pointing anything at it.
ls -l /run/user/$(id -u)/podman/podman.sock

# Point the real Docker Compose at Podman instead of dockerd.
export DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock
docker compose up -d</code></pre>



<p class="wp-block-paragraph">You keep the tool that tracks the Compose specification and swap only the engine underneath. The catch is that the API is a compatibility layer, not the Docker daemon, so behaviour can differ at the edges. Run your real compose file through it before you commit to anything.</p>



<h3 class="wp-block-heading">2. Anything that mounts the Docker socket</h3>



<p class="wp-block-paragraph">Traefik discovering containers by label, Portainer, Testcontainers in your test suite, any CI job that does Docker-in-Docker. All of these want <code>/var/run/docker.sock</code>. The Podman socket serves the same API and many of them work against it unchanged, but &#8220;many&#8221; is doing work in that sentence. Inventory what mounts the socket <em>before</em> you plan the migration, not after.</p>



<h3 class="wp-block-heading">3. Ports below 1024</h3>



<p class="wp-block-paragraph">A rootless container cannot bind a privileged port, because the process genuinely is not privileged. If your compose file publishes 80 and 443, it will fail, and the error will not obviously say why.</p>



<p class="wp-block-paragraph">Three ways out, in the order I would try them:</p>



<ol class="wp-block-list">
<li><strong>Publish a high port and put a host reverse proxy in front.</strong> Nginx or Caddy running as a normal system service, listening on 443, proxying to 8443. No kernel tuning, and you probably wanted TLS termination on the host anyway.</li>

<li><strong>Lower the privileged port threshold.</strong> The kernel exposes <code>net.ipv4.ip_unprivileged_port_start</code>, and setting it to 0 lets any user bind any port. It works, and it is a host-wide change that weakens a defence for every process on the machine, not just yours. Understand that before you write it into a playbook.</li>

<li><strong>Run that one container rootful.</strong> Podman supports it. Mixing modes is a legitimate choice as long as it is deliberate and documented rather than something that happened.</li>
</ol>



<h3 class="wp-block-heading">4. Restart on boot</h3>



<p class="wp-block-paragraph">This is the one that catches people who tested on a laptop and shipped to a server. Rootless containers belong to a user session, and a user&#8217;s services are stopped when their last session ends. Reboot the box and nothing comes back, no matter what restart policy you set.</p>



<p class="wp-block-paragraph">The fix is two parts. Enable lingering so the user&#8217;s systemd instance runs without a login, and define the container as a Quadlet unit rather than relying on a restart policy:</p>



<pre class="wp-block-code"><code># Let this user's services run without an active login session.
sudo loginctl enable-linger appuser

# Verify. Linger=yes is the line that matters.
loginctl show-user appuser --property=Linger</code></pre>



<p class="wp-block-paragraph">Then the unit itself. Quadlet files look like systemd units with a Podman-specific section, and a generator turns them into real services at boot and on <code>daemon-reload</code>. Rootless units live in <code>~/.config/containers/systemd/</code>; system ones in <code>/etc/containers/systemd/</code>.</p>



<pre class="wp-block-code"><code># ~/.config/containers/systemd/api.container

[Unit]
Description=Application API
After=network-online.target

[Container]
# Fully qualified. Podman does not assume Docker Hub.
Image=registry.example.com/api:1.4.2
PublishPort=8080:8080
Volume=api-data.volume:/var/lib/api
Environment=NODE_ENV=production

[Service]
Restart=always

[Install]
# Starts at boot once lingering is enabled for this user.
WantedBy=default.target</code></pre>



<pre class="wp-block-code"><code># Regenerate services from Quadlet files, then start it.
systemctl --user daemon-reload
systemctl --user start api

# From here it is an ordinary service.
systemctl --user status api
journalctl --user -u api -f</code></pre>



<p class="wp-block-paragraph">Note that <code>podman generate systemd</code>, which you will find in older tutorials, is deprecated in favour of Quadlet. Podman&#8217;s documentation says it receives urgent bug fixes only and no new features. Do not build anything new on it.</p>



<p class="wp-block-paragraph">One smaller trap while you are here: Podman does not assume Docker Hub for unqualified image names. <code>nginx:alpine</code> may prompt you to choose a registry or fail outright depending on configuration. Write <code>docker.io/library/nginx:alpine</code>, or whatever your registry is, everywhere. This is arguably the correct behaviour and it is still an afternoon of edits.</p>



<h2 class="wp-block-heading">Podman vs Docker: how I would decide</h2>



<p class="wp-block-paragraph">Run these in order and stop at the first one that answers.</p>



<ol class="wp-block-list">
<li><strong>Do you use Swarm, or Docker volume or network plugins?</strong> Stay on Docker. Podman does not implement them and no workaround changes that.</li>

<li><strong>Is a compliance requirement or an access review driving this?</strong> Switch, or at minimum move to rootless. &#8220;The docker group is root&#8221; is a finding you cannot argue with, and rootless Podman is the shortest path to closing it.</li>

<li><strong>Are you on RHEL, Fedora, Rocky or AlmaLinux?</strong> Lean Podman. It is the packaged, supported, tested-by-the-vendor option on those distributions, and you are swimming with the current instead of against it.</li>

<li><strong>Is Docker Desktop licensing a real cost for your organisation?</strong> Check the thresholds against your actual headcount and revenue before assuming either way. If you are over, this is a budget conversation with a free technical answer, which is a rare shape of problem.</li>

<li><strong>Does your test suite or CI depend on the Docker socket?</strong> Prototype that specific dependency against the Podman socket first. It is the highest-risk item and the cheapest to test. If it fails, everything else is academic.</li>

<li><strong>None of the above, and Docker is working?</strong> Stay. &#8220;Podman has nicer architecture&#8221; is true and it is not a business case. Migrations cost weeks, introduce risk, and the containers you are already running do not care which engine started them.</li>
</ol>



<p class="wp-block-paragraph">The honest summary: Podman is the better-designed engine and Docker is the better-supported one. Design wins when you are building something new or when a policy requirement forces the question. Support wins when you have a working system and a limited number of weeks.</p>



<h2 class="wp-block-heading">If you do switch, do it in this order</h2>



<ol class="wp-block-list">
<li><strong>Inventory the socket mounts.</strong> Grep your compose files and CI config for <code>docker.sock</code>. That list is your actual migration scope.</li>

<li><strong>Stand up one non-critical service on Podman.</strong> A staging box, an internal tool, something on a spare VPS. Run it for a fortnight through at least one reboot before you draw conclusions.</li>

<li><strong>Fully qualify every image reference.</strong> Do this in the compose files while they still run under Docker. It changes nothing there and removes a whole class of failure later.</li>

<li><strong>Switch the engine, keep Compose.</strong> Podman socket plus <code>DOCKER_HOST</code>. Change one variable at a time so you know what broke.</li>

<li><strong>Convert to Quadlet last, and only where it earns its place.</strong> Long-lived services benefit. A three-container stack you rebuild constantly is fine staying on Compose.</li>
</ol>



<h2 class="wp-block-heading">Arguments that don&#8217;t survive contact</h2>



<ul class="wp-block-list">
<li><strong>&#8220;Podman is a drop-in replacement.&#8221;</strong> The CLI nearly is. The surrounding tooling is not, and that is where the weeks go.</li>

<li><strong>&#8220;Daemonless means faster.&#8221;</strong> The engine is not your bottleneck. Rootless networking has historically been slower than a root bridge, not faster. Pick Podman for the security model, not for benchmark numbers.</li>

<li><strong>&#8220;Rootless means secure.&#8221;</strong> It means a break-out lands somewhere less catastrophic. A container running rootless with the host filesystem bind-mounted read-write is still a bad idea.</li>

<li><strong>&#8220;Docker is dying.&#8221;</strong> It is not. Docker Engine, Compose and Buildx are actively developed and hosted CI runners still ship them by default.</li>

<li><strong>&#8220;Just alias docker to podman and you&#8217;re done.&#8221;</strong> The alias handles the commands. It does not handle the socket, the ports, the boot behaviour or the image names.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Can Podman run docker-compose files?</h3>



<p class="wp-block-paragraph">Yes, by two routes. Install <code>podman-compose</code>, which is a separate implementation with good but incomplete coverage, or enable the Podman socket and point real Docker Compose at it with <code>DOCKER_HOST</code>. The second gives better specification coverage because you are running the actual Compose tool. Neither is a guarantee; test your own file.</p>



<h3 class="wp-block-heading">Is Podman actually more secure than Docker?</h3>



<p class="wp-block-paragraph">Its default posture is better. Rootless out of the box means no root daemon and no group that is root by another name. Docker can be configured to a similar posture with rootless mode, so the difference is less &#8220;Podman is secure and Docker is not&#8221; and more &#8220;Podman&#8217;s safe path is the easy path&#8221;. Defaults determine what most installations actually run.</p>



<h3 class="wp-block-heading">Do I need to pay for Docker?</h3>



<p class="wp-block-paragraph">Docker Engine on Linux is open source and free. Docker Desktop is the licensed product. Per Docker&#8217;s terms it is free for personal use, education, non-commercial open source, and businesses with fewer than 250 employees and under ten million dollars in annual revenue; cross either threshold, or be a government entity, and commercial use requires a subscription. If you only run Docker Engine on Linux servers, the licensing question does not apply to you.</p>



<h3 class="wp-block-heading">Why do my Podman containers disappear after a reboot?</h3>



<p class="wp-block-paragraph">Because rootless containers run under your user&#8217;s systemd instance, which is torn down when your last session ends. Run <code>loginctl enable-linger</code> for the service account and define the containers as Quadlet units with <code>WantedBy=default.target</code>. A Podman restart policy alone will not survive a reboot.</p>



<h3 class="wp-block-heading">What is Quadlet and do I have to use it?</h3>



<p class="wp-block-paragraph">Quadlet is a systemd generator that turns declarative container definitions into real systemd services. You do not have to use it, but the alternative is hand-writing unit files, and the older <code>podman generate systemd</code> command is deprecated in favour of it. For anything long-lived on a server, Quadlet is the current answer.</p>



<h3 class="wp-block-heading">Can Podman and Docker coexist on the same host?</h3>



<p class="wp-block-paragraph">Yes. They keep separate image and container stores, so nothing collides at the storage layer. Watch for port conflicts if both are running services, and be deliberate about which one a given script is talking to, because <code>DOCKER_HOST</code> and an alias can quietly disagree with each other.</p>



<h3 class="wp-block-heading">Does Podman work with Kubernetes?</h3>



<p class="wp-block-paragraph">Better than Docker does, in the sense that it speaks the format natively. <code>podman kube play</code> runs a Kubernetes manifest on a single host and <code>podman generate kube</code> produces one from running containers. It is not a substitute for a cluster, but as a local development story for a Kubernetes destination it is the closest thing available.</p>



<h2 class="wp-block-heading">The one thing worth remembering</h2>



<p class="wp-block-paragraph">The Podman vs Docker decision is not really about container engines. Both run OCI images and both will start your workload. It is about whether a root daemon and a root-equivalent group are acceptable in your environment, and whether you have the weeks to spend on the four things that break.</p>



<p class="wp-block-paragraph">If a policy requirement is driving the question, switch and budget properly for the compose and socket work. If you are on an enterprise Linux distribution and starting something new, start on Podman, because you will be swimming with the current. If Docker is working and nothing external is forcing the question, the correct engineering answer is to leave it alone and go fix something that is actually broken.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Thinking about a migration and want a second opinion?</h2>



<p class="wp-block-paragraph">I help teams decide this question and then execute it without a surprise outage. Things I am usually brought in for:</p>



<ul class="wp-block-list">
<li>Auditing what on your hosts actually depends on the Docker socket, and scoping the migration honestly before anyone commits to it</li>

<li>Testing your real compose files against the Podman socket and reporting what breaks, rather than guessing from a feature table</li>

<li>Converting long-lived services to Quadlet units with proper systemd ordering, health gating and journald logging</li>

<li>Sorting out rootless networking: privileged ports, reverse proxy placement, and the source-IP behaviour that surprises people</li>

<li>Closing the &#8220;docker group equals root&#8221; audit finding, whether that means Podman, rootless Docker, or removing the group entirely</li>

<li>Writing the decision up so it survives the next person who asks why you chose what you chose</li>
</ul>



<p class="wp-block-paragraph">If you are weighing this up, send me a compose file and a list of what mounts the socket. That is usually enough to tell you whether the switch is a fortnight or a quarter.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/podman-vs-docker-worth-it/">Podman vs Docker: Is the Switch Actually Worth It?</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</title>
		<link>https://john-nessime.com/blog/system-administration/vps-hardening-checklist/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:24 +0000</pubDate>
				<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Web Security]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[Fail2ban]]></category>
		<category><![CDATA[Firewall]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[OpenSSH]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Server Hardening]]></category>
		<category><![CDATA[SSH]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[UFW]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=62</guid>

					<description><![CDATA[<p>You set PasswordAuthentication no, restarted SSH, and moved on. Months later the logs show successful password logins, because a vendor drop-in file sorted earlier and won. A VPS setup and hardening checklist where every step comes with the command that proves it worked.</p>
<p>The post <a href="https://john-nessime.com/blog/system-administration/vps-hardening-checklist/">You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You spun up a new box, worked through a hardening guide, set <code>PasswordAuthentication no</code>, restarted SSH, and moved on. Six months later you are reading auth logs for an unrelated reason and there it is: successful password logins. Not attempts. Logins.</p>



<p class="wp-block-paragraph">The line you wrote was real. The file was saved. What you did not know is that your distro puts an <code>Include /etc/ssh/sshd_config.d/*.conf</code> near the top of <code>sshd_config</code>, that sshd uses the <em>first</em> value it finds for any given keyword, and that your cloud provider&#8217;s image dropped a <code>50-cloud-init.conf</code> in that directory setting <code>PasswordAuthentication yes</code>. It sorts earlier. It wins. Your line at the bottom of the main file was never reached.</p>



<p class="wp-block-paragraph">You never noticed because you log in with a key anyway. Nothing broke. The control just was not there.</p>



<p class="wp-block-paragraph">That is the thing most guides get wrong, and it is the idea this <strong>VPS hardening checklist</strong> is built around: a hardening step is not done when you have edited the file. It is done when you have checked the effective configuration and seen the value you expected. Everything below comes with the command that proves it.</p>



<p class="wp-block-paragraph">This covers the order to do things in so you do not lock yourself out, SSH keys and config, the firewall, automatic updates, fail2ban and whether it is worth it, the unglamorous things that matter more than any of it, and what I would skip.</p>



<h2 class="wp-block-heading">Before you touch anything</h2>



<p class="wp-block-paragraph">Two minutes of preparation that saves an evening.</p>



<p class="wp-block-paragraph"><strong>Find your provider&#8217;s console before you need it.</strong> Whether you are on InterServer, Hetzner, DigitalOcean or anything else, there is a web-based serial or VNC console that works when SSH does not. Open it now, log in as root, and confirm it works. This is your parachute. If you are going to lock yourself out, you want to already know where it is.</p>



<p class="wp-block-paragraph"><strong>Never close the session you are working in.</strong> Every SSH change gets tested from a second terminal while the first one stays open. If the new settings are broken, the old session is still authenticated and you can undo them. Close it too early and your only route back in is the console.</p>



<p class="wp-block-paragraph">Then update the system, because everything else assumes current packages:</p>



<pre class="wp-block-code"><code>sudo apt update &amp;&amp; sudo apt upgrade -y
sudo reboot   # if a kernel update landed</code></pre>



<h2 class="wp-block-heading">1. A real user, and keys instead of passwords</h2>



<p class="wp-block-paragraph">Generate the key on your own machine, not on the server. A private key that has been on a server is not a private key any more.</p>



<pre class="wp-block-code"><code># On your laptop. ed25519 is the sane default: short keys,
# fast verification, no parameter choices to get wrong.
ssh-keygen -t ed25519 -C "deploy@my-laptop"</code></pre>



<pre class="wp-block-code"><code># On the server, as root.
adduser deploy
usermod -aG sudo deploy        # 'wheel' instead of 'sudo' on RHEL-family

# Back on your laptop. ssh-copy-id appends to authorized_keys
# and sets the permissions correctly, which is the part people
# get wrong when they paste the key by hand.
ssh-copy-id deploy@203.0.113.10</code></pre>



<p class="wp-block-paragraph">Now open a second terminal and confirm <code>ssh deploy@203.0.113.10</code> works, and that <code>sudo -v</code> works once you are in. Only then move on. If either fails, fix it before you disable anything, because in a minute you will be removing the fallback.</p>



<h2 class="wp-block-heading">2. SSH config, and why your edit might do nothing</h2>



<p class="wp-block-paragraph">This is the section that actually matters. Two mechanisms silently override what you write.</p>



<h3 class="wp-block-heading">Drop-in files and first-match-wins</h3>



<p class="wp-block-paragraph">Modern OpenSSH packages ship an <code>Include</code> line near the top of <code>/etc/ssh/sshd_config</code>, which pulls in every <code>.conf</code> file from <code>/etc/ssh/sshd_config.d/</code> in alphabetical order, before the rest of the main file is read. Combine that with sshd&#8217;s rule that the first value found for a keyword wins, and the consequence is specific: <strong>your drop-in has to sort earlier than the vendor&#8217;s, not later.</strong></p>



<p class="wp-block-paragraph">Most guides tell you to name it <code>99-something.conf</code>, which is the convention almost everywhere else in Linux and exactly backwards here.</p>



<pre class="wp-block-code"><code># /etc/ssh/sshd_config.d/00-hardening.conf
# Sorts before 50-cloud-init.conf, so these values are seen first.

PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

# Optional: restrict to the accounts that should have shell access.
AllowUsers deploy</code></pre>



<p class="wp-block-paragraph">A note on <code>KbdInteractiveAuthentication</code>: older guides and older OpenSSH call this <code>ChallengeResponseAuthentication</code>. Setting only <code>PasswordAuthentication no</code> can still leave a keyboard-interactive path open on some configurations, which is why both are here.</p>



<p class="wp-block-paragraph">Now the part nobody does. Validate the syntax, then read the effective config:</p>



<pre class="wp-block-code"><code># Syntax check. Prints nothing if the config is valid.
# Run this BEFORE restarting, always.
sudo sshd -t

# Dump the effective configuration with every include resolved.
# This is the only output that tells you the truth.
sudo sshd -T | grep -Ei 'permitrootlogin|passwordauthentication|kbdinteractive|pubkeyauth'</code></pre>



<p class="wp-block-paragraph">If <code>passwordauthentication yes</code> comes back, your file lost to something else in that directory. Look at what is in there with <code>ls /etc/ssh/sshd_config.d/</code> and either rename yours to sort earlier or edit the offending file directly.</p>



<h3 class="wp-block-heading">Socket activation, and the port that will not change</h3>



<p class="wp-block-paragraph">The second trap. On several current distro releases, SSH is started by systemd socket activation rather than as a persistent service: <code>ssh.socket</code> holds the listening port and starts <code>sshd</code> only when a connection arrives. On those systems the <code>Port</code> and <code>ListenAddress</code> directives in <code>sshd_config</code> may be ignored entirely, because systemd owns the socket.</p>



<p class="wp-block-paragraph">People change the port, restart the service, see no error, and assume it worked. Check what is actually bound:</p>



<pre class="wp-block-code"><code># -t TCP, -l listening, -n numeric, -p show the owning process
sudo ss -tlnp</code></pre>



<p class="wp-block-paragraph">If you genuinely want a different port on a socket-activated system, override the socket unit with a proper drop-in rather than editing files under <code>/lib/systemd/system/</code>, which a package update will overwrite:</p>



<pre class="wp-block-code"><code>sudo systemctl edit ssh.socket</code></pre>



<pre class="wp-block-code"><code># In the editor that opens:
[Socket]
# The empty assignment clears the inherited value. Without it
# you end up listening on both the old port and the new one.
ListenStream=
ListenStream=2222</code></pre>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl restart ssh.socket
sudo ss -tlnp        # confirm before you trust it</code></pre>



<p class="wp-block-paragraph">Honest assessment of port changes: this is not security. Anyone scanning your address finds an SSH daemon on a non-standard port in seconds. What it genuinely does is cut the volume of automated login noise in your logs by a large margin, which makes real events easier to see. That is a maintenance benefit, not a defence, and it is worth doing for that reason alone. Just do not count it as one of your controls.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3. Firewall, in the right order</h2>



<p class="wp-block-paragraph">The ordering here is the whole trick. Set the default policy and add your rules <em>before</em> you enable the firewall. Enable first and you will drop your own connection with the policy that denies everything.</p>



<pre class="wp-block-code"><code>sudo ufw default deny incoming
sudo ufw default allow outgoing

# Allow SSH BEFORE enabling. If you changed the port,
# allow that port instead of the OpenSSH profile.
sudo ufw allow OpenSSH
# sudo ufw allow 2222/tcp

sudo ufw allow 80,443/tcp

sudo ufw enable
sudo ufw status verbose</code></pre>



<p class="wp-block-paragraph">Read that <code>status verbose</code> output rather than assuming. It shows the default policies and every rule, and it is the proof that this step is done.</p>



<p class="wp-block-paragraph">One thing worth knowing if you run containers: Docker writes its own rules directly into netfilter and bypasses ufw. A container published with <code>-p 5432:5432</code> is reachable from the internet even though ufw says that port is denied. Bind container ports to <code>127.0.0.1</code> explicitly, or put the service behind a reverse proxy, and verify from outside the box with a port scan rather than trusting the firewall status.</p>



<p class="wp-block-paragraph">A stronger option worth considering: do not expose SSH to the internet at all. Put the server on a private overlay network with something like Tailscale or a WireGuard tunnel, bind sshd to that interface, and close port 22 to the world entirely. More setup, and it removes an entire class of problem rather than filtering it.</p>



<h2 class="wp-block-heading">4. Automatic security updates</h2>



<p class="wp-block-paragraph">Unpatched packages are a far more realistic route into your server than an SSH brute force. This is the highest-value item on the list and it takes two commands.</p>



<pre class="wp-block-code"><code>sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades

# Verify. Note the binary is singular, the package is plural.
# --dry-run makes no changes.
sudo unattended-upgrade --dry-run --debug</code></pre>



<p class="wp-block-paragraph">By default this applies security updates only, which is the right scope. Enabling automatic reboots is a real trade-off: it closes kernel vulnerabilities without you doing anything, and it will restart your server at whatever hour you configure. On a single web server with a proper service startup order, I turn it on. On anything holding state that does not recover cleanly from a hard restart, I leave it off and patch by hand.</p>



<h2 class="wp-block-heading">5. fail2ban, and what it is actually for</h2>



<p class="wp-block-paragraph">Install it, but be clear about the role. Once password authentication is off and root login is disabled, a brute force against sshd cannot succeed. There is no password to guess. fail2ban is not what is keeping those attackers out; your key-only config is.</p>



<p class="wp-block-paragraph">What it does earn its place for is reducing log noise and CPU spent on connection handling, and protecting the services where credentials <em>do</em> exist: a mail server, a web application login form, an FTP daemon.</p>



<pre class="wp-block-code"><code>sudo apt install fail2ban

# Do not edit jail.conf; it gets replaced on upgrade.
sudo cp /etc/fail2ban/jail.conf /etc/fail2ban/jail.local

sudo systemctl enable --now fail2ban
sudo systemctl status fail2ban
sudo fail2ban-client status sshd</code></pre>



<p class="wp-block-paragraph">Check that <code>status</code> output properly. fail2ban has shipped in states where the service fails to start on a given distro release because the packaged version does not match the system Python, and it fails loudly in the journal and silently everywhere else. A stopped fail2ban looks exactly like a working one from the outside. If it is not running, <code>journalctl -u fail2ban</code> will tell you why.</p>



<p class="wp-block-paragraph">Set <code>ignoreip</code> in <code>jail.local</code> to include your office or home address. Banning yourself is a rite of passage but it is avoidable.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">6. The boring items that matter more than the exciting ones</h2>



<p class="wp-block-paragraph">Nobody writes blog posts about these, and they cause more real outages than any of the above.</p>



<h3 class="wp-block-heading">Provider account security</h3>



<p class="wp-block-paragraph">Turn on two-factor authentication in your hosting control panel and your domain registrar. Someone with your provider login does not need to defeat sshd; they can reset the root password from the console or attach your disk to a machine they control. This is the weakest link on most setups and it takes two minutes.</p>



<h3 class="wp-block-heading">Time and timezone</h3>



<pre class="wp-block-code"><code>sudo timedatectl set-timezone UTC
timedatectl status       # look for "System clock synchronized: yes"</code></pre>



<p class="wp-block-paragraph">UTC on servers, always. Correlating logs across machines in different local times, one of which observes daylight saving, is a genuinely miserable way to spend an incident.</p>



<h3 class="wp-block-heading">Swap on small instances</h3>



<pre class="wp-block-code"><code># A little swap stops the OOM killer choosing your database
# during a traffic spike. It is not a substitute for RAM.
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
free -h</code></pre>



<h3 class="wp-block-heading">Backups that leave the machine</h3>



<p class="wp-block-paragraph">Your provider&#8217;s snapshots live in the same account as the server. If that account is compromised, or you delete the wrong thing, they go with it. A snapshot is a rollback mechanism, not a backup.</p>



<p class="wp-block-paragraph">Get database dumps and application data onto storage under a different set of credentials, on a schedule, and then do the part everyone skips: restore one. A backup you have never restored is a hypothesis. Point a dead man&#8217;s switch service at the backup job so you find out when it stops running, rather than the day you need it.</p>



<h3 class="wp-block-heading">Knowing the box is alive</h3>



<p class="wp-block-paragraph">At minimum, an external uptime check that alerts you. Better, node metrics into Prometheus with Grafana dashboards, or a hosted equivalent. Disk filling up is the single most common way a small VPS falls over, and it is completely predictable if anything is watching the graph.</p>



<h2 class="wp-block-heading">What I would skip</h2>



<p class="wp-block-paragraph">Not everything in the long hardening guides earns its cost on a single web server.</p>



<ul class="wp-block-list">
<li><strong>Large sysctl hardening blocks pasted from a gist.</strong> Most of the values are already the default on a current kernel, several are cargo-culted from a decade ago, and one of them will break something in a way that takes hours to trace. Change kernel parameters when you understand what each one does.</li>
<li><strong>AIDE, auditd and file-integrity monitoring</strong> on a small server nobody is watching. These produce a large volume of output that needs a human to review. If nobody reads it, it is not a control, it is just disk usage.</li>
<li><strong>Rebuilding OpenSSH&#8217;s cipher list.</strong> Current defaults are good. Guides that hand you a <code>Ciphers</code> line are usually copying advice written for a version that shipped years ago, and you can pin yourself to something weaker than the default without realising.</li>
<li><strong>Disabling ping.</strong> It breaks your own monitoring and hides nothing.</li>
</ul>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Locked out after an SSH change</h3>



<p class="wp-block-paragraph">Provider console, log in as root, and revert. If you cannot remember what you changed, <code>ls -lt /etc/ssh/sshd_config.d/</code> shows the most recently modified file. Run <code>sshd -t</code> to see whether the config is even valid, since a syntax error stops sshd starting at all.</p>



<h3 class="wp-block-heading">Key auth rejected, password prompt appears instead</h3>



<p class="wp-block-paragraph">Almost always permissions. The home directory must not be group-writable, <code>~/.ssh</code> needs 700 and <code>authorized_keys</code> 600, both owned by the user. sshd refuses keys from world-writable locations and does not explain itself on the client side. Run <code>ssh -v</code> to see how far it gets, and check <code>journalctl -u ssh</code> on the server for the actual reason.</p>



<h3 class="wp-block-heading">The setting I applied is not in effect</h3>



<p class="wp-block-paragraph"><code>sudo sshd -T</code> and search for the keyword. If the effective value differs from your file, something in <code>sshd_config.d/</code> sorts earlier, or a <code>Match</code> block later in the config is overriding it for your connection.</p>



<h3 class="wp-block-heading">A service is reachable that the firewall says is blocked</h3>



<p class="wp-block-paragraph">Docker, or another daemon writing netfilter rules directly. Check with <code>sudo iptables -S</code> or <code>sudo nft list ruleset</code>, and always verify from a machine outside the server rather than trusting <code>ufw status</code>.</p>



<h3 class="wp-block-heading">Nothing obviously wrong, but who has been logging in?</h3>



<pre class="wp-block-code"><code>last              # successful logins
sudo lastb        # failed attempts, from /var/log/btmp
journalctl -u ssh --since "24 hours ago"</code></pre>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Closing your only SSH session before testing the new configuration from a second one.</li>
<li>Enabling the firewall before adding the SSH rule.</li>
<li>Naming your sshd drop-in <code>99-</code>, so a vendor file that sorts earlier wins.</li>
<li>Trusting the file you edited instead of running <code>sshd -T</code>.</li>
<li>Generating the SSH key on the server.</li>
<li>Changing the SSH port and counting it as a security control.</li>
<li>Disabling password auth for SSH while leaving a web control panel with a weak password exposed.</li>
<li>No two-factor authentication on the hosting account that can reset root.</li>
<li>Treating provider snapshots as backups.</li>
<li>Installing fail2ban and never checking that it started.</li>
<li>Publishing container ports without binding them to localhost, then assuming ufw covers it.</li>
<li>Pasting a sysctl block you cannot explain line by line.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Verify every control after applying it, with a command that reads the running state.</li>
<li>Keep a second session open and the provider console tested before making SSH changes.</li>
<li>Key-only authentication, root login disabled, and an explicit <code>AllowUsers</code> list.</li>
<li>Default-deny inbound firewall, with rules added before the firewall is enabled.</li>
<li>Automatic security updates on, with a deliberate decision about automatic reboots.</li>
<li>Two-factor authentication on the hosting account and the registrar.</li>
<li>Backups off the machine, on separate credentials, and restore-tested at least once.</li>
<li>UTC everywhere, with clock sync confirmed.</li>
<li>Monitoring that pages you on disk usage before it pages you on downtime.</li>
<li>Script the whole build, so the next server is identical and you are not repeating this from memory.</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">FAQ</h2>



<h3 class="wp-block-heading">What is the single most important step?</h3>



<p class="wp-block-paragraph">Key-only SSH with root login disabled, verified with <code>sshd -T</code>. It removes the entire category of credential guessing. Automatic security updates is a close second, because unpatched software is the more realistic way in once passwords are off the table.</p>



<h3 class="wp-block-heading">Should I change the SSH port?</h3>



<p class="wp-block-paragraph">For quieter logs, yes. As a security measure, no. A scanner finds the daemon regardless. The practical benefit is that failed-login noise drops enough that a genuine anomaly becomes visible, which has real value for anyone actually reading their logs.</p>



<h3 class="wp-block-heading">Do I need fail2ban if password authentication is off?</h3>



<p class="wp-block-paragraph">Not for SSH specifically. Install it for the services that still accept credentials, and for the reduction in log volume and connection churn. Just do not let it occupy the slot in your head reserved for an actual defence.</p>



<h3 class="wp-block-heading">Why did my sshd_config change get ignored?</h3>



<p class="wp-block-paragraph">Two likely causes. A file in <code>/etc/ssh/sshd_config.d/</code> that sorts alphabetically earlier set the same keyword first, and sshd keeps the first value it sees. Or the setting is <code>Port</code> or <code>ListenAddress</code> on a socket-activated system, where systemd owns the listener. <code>sshd -T</code> answers the first case and <code>ss -tlnp</code> answers the second.</p>



<h3 class="wp-block-heading">Is ufw enough, or should I use nftables directly?</h3>



<p class="wp-block-paragraph">ufw is enough for a single server with a handful of open ports, and it is a front end to the same machinery underneath. Write nftables rules directly when you need something ufw cannot express cleanly, such as rate limiting or complex source-based rules. Do not switch tools for its own sake; a firewall you understand beats an elegant one you do not.</p>



<h3 class="wp-block-heading">How do I harden a server I did not build?</h3>



<p class="wp-block-paragraph">Audit before you change anything. <code>sshd -T</code>, <code>ufw status verbose</code>, <code>ss -tlnp</code> for unexpected listeners, <code>systemctl list-units --type=service --state=running</code>, and the contents of every <code>authorized_keys</code> on the box. Old keys belonging to people who left are extremely common and nobody ever removes them.</p>



<h3 class="wp-block-heading">Should this be automated?</h3>



<p class="wp-block-paragraph">Yes, once you have done it manually enough times to understand each step. An Ansible playbook or a cloud-init script makes the next server identical and turns your hardening into something reviewable in version control. Automating a process you do not yet understand just makes the mistakes reproducible.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The one thing to remember</h2>



<p class="wp-block-paragraph">Editing a config file is not the same as changing a system&#8217;s behaviour. Between your file and the running daemon sit include directives, drop-in ordering, systemd units and packages that ship their own opinions, and any one of them can quietly discard what you wrote without an error message.</p>



<p class="wp-block-paragraph">So treat this VPS hardening checklist as a set of claims to be tested rather than a list to be ticked. <code>sshd -T</code> for SSH, <code>ufw status verbose</code> for the firewall, <code>ss -tlnp</code> for what is listening, <code>systemctl status</code> for anything you installed. If you cannot show the control from the running system, you have not applied it. You have only intended to.</p>



<h2 class="wp-block-heading">Want a second pair of eyes on your server?</h2>



<p class="wp-block-paragraph">Most of the servers I get handed were hardened once, by someone who has since moved on, and nobody has verified anything since. Work I take on:</p>



<ul class="wp-block-list">
<li>Auditing an existing VPS and reporting which controls are genuinely in effect versus configured but overridden.</li>
<li>Building a new server from scratch: users, SSH, firewall, automatic updates, monitoring, backups, all verified.</li>
<li>Turning a manual build into an Ansible playbook or cloud-init script so the next server matches this one.</li>
<li>Moving SSH off the public internet onto a private overlay network or bastion.</li>
<li>Backup strategy that survives the hosting account being compromised, including a tested restore.</li>
<li>Firewall and container networking, especially where published Docker ports are bypassing the firewall.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>sshd -T</code>, <code>ufw status verbose</code> and <code>ss -tlnp</code>, and I will tell you what stands out.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/system-administration/vps-hardening-checklist/">You Probably Didn&#8217;t Harden That Server: A VPS Setup and Hardening Checklist That Verifies Itself</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Cleaning Up Docker Disk Usage Safely</title>
		<link>https://john-nessime.com/blog/devops/docker-disk-usage-cleanup/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 19:52:19 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=46</guid>

					<description><![CDATA[<p>The top search result tells you to run docker system prune -a --volumes. It frees the space and deletes your database. Here is how to measure Docker disk usage properly, reclaim it from safest to most destructive, and fix the logs that Docker never reports on.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-disk-usage-cleanup/">Cleaning Up Docker Disk Usage Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">The alert says the root partition is at 97%. You SSH in, run <code>df -h</code>, and <code>/var/lib/docker</code> is sitting on forty-something gigabytes. The first search result tells you to run <code>docker system prune -a --volumes</code>, and it will absolutely free the space. It will also delete the volume holding your Postgres data.</p>



<p class="wp-block-paragraph">Docker disk usage grows quietly because every part of it accumulates independently — images, stopped containers, volumes, build cache, and container logs, which are the one nobody expects and which no Docker command reports on. Cleaning it up is easy. Cleaning it up without destroying something takes about five more minutes.</p>



<p class="wp-block-paragraph">This is the order I work through it: measure first, reclaim from safest to most destructive, then fix the thing that caused the growth so you are not back here next month.</p>



<h2 class="wp-block-heading">Measure before you delete</h2>



<p class="wp-block-paragraph">Every cleanup starts here, and skipping it is how people delete the wrong thing:</p>



<pre class="wp-block-code"><code>docker system df</code></pre>



<p class="wp-block-paragraph">That gives you four rows — Images, Containers, Local Volumes, Build Cache — with a total size and a reclaimable figure for each. The reclaimable column is the one that matters. If build cache is eleven gigabytes and all of it is reclaimable, you have found your win and you never need to go near volumes.</p>



<p class="wp-block-paragraph">For the itemised version:</p>



<pre class="wp-block-code"><code>docker system df -v</code></pre>



<p class="wp-block-paragraph">Now you can see individual images with their sizes, which containers are using what, and which volumes have no container attached. Read this before running anything destructive.</p>



<p class="wp-block-paragraph">Two more views worth having open:</p>



<pre class="wp-block-code"><code># What is actually running, and what is just sitting there stopped
docker ps -a --format 'table {{.Names}}t{{.Image}}t{{.Status}}t{{.Size}}'

# Images by age, oldest first
docker images --format 'table {{.Repository}}:{{.Tag}}t{{.Size}}t{{.CreatedSince}}'</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Reclaim in order, safest first</h2>



<p class="wp-block-paragraph">These are ordered deliberately. Work down the list and stop as soon as you have enough space. Most of the time you never reach step four.</p>



<h3 class="wp-block-heading">1. Stopped containers</h3>



<pre class="wp-block-code"><code># See what would go
docker ps -a --filter status=exited --filter status=created

docker container prune</code></pre>



<p class="wp-block-paragraph">This removes stopped containers and their writable layers. Anything running is untouched, and named volumes attached to those containers survive — only the container&#8217;s own layer goes.</p>



<p class="wp-block-paragraph">The one caveat: if a stopped container held state in its writable layer rather than in a volume, that state is gone. That is a design problem rather than a cleanup problem, but it is worth a glance before you confirm.</p>



<h3 class="wp-block-heading">2. Build cache</h3>



<p class="wp-block-paragraph">On any machine that builds images regularly, this is usually the biggest single win and it is completely safe. Build cache is derived data. Deleting it costs you a slower next build and nothing else.</p>



<pre class="wp-block-code"><code># Everything older than a week
docker builder prune --filter "until=168h"

# Everything, no exceptions
docker builder prune -a</code></pre>



<h3 class="wp-block-heading">3. Dangling images</h3>



<pre class="wp-block-code"><code># Look first
docker images -f dangling=true

docker image prune</code></pre>



<p class="wp-block-paragraph">Without <code>-a</code>, this removes only dangling images — untagged layers left behind when a rebuild moved a tag to a new image. They are almost always genuine garbage.</p>



<h3 class="wp-block-heading">4. Unused images — read this one carefully</h3>



<pre class="wp-block-code"><code>docker image prune -a</code></pre>



<p class="wp-block-paragraph">Adding <code>-a</code> changes the meaning completely. It removes every image that does not have at least one container associated with it — running <em>or</em> stopped. So an image you pulled for a job that runs weekly, with no container currently existing, is gone. It will pull again, which is fine if you have bandwidth and the registry is reachable, and much less fine at 3am on a machine with a slow link.</p>



<p class="wp-block-paragraph">Safer in most cases is an age filter, which keeps anything recent:</p>



<pre class="wp-block-code"><code>docker image prune -a --filter "until=336h"   # older than 14 days</code></pre>



<h3 class="wp-block-heading">5. Volumes — the one that loses data</h3>



<p class="wp-block-paragraph">Volumes are where databases live. Treat this step as a data operation, not a cleanup.</p>



<p class="wp-block-paragraph">Before removing anything, look inside the candidates. This mounts each dangling volume read-only into a throwaway container so you can see what is actually in it:</p>



<pre class="wp-block-code"><code>docker volume ls -qf dangling=true | while read -r v; do
  echo "=== $v"
  docker run --rm -v "$v":/vol:ro alpine sh -c 'du -sh /vol; ls -la /vol' 2&gt;/dev/null | head -12
done</code></pre>



<p class="wp-block-paragraph">If you see anything resembling a data directory, back it up before it goes:</p>



<pre class="wp-block-code"><code>docker run --rm 
  -v my-volume:/vol:ro 
  -v "$PWD":/backup 
  alpine tar czf /backup/my-volume.tar.gz -C /vol .</code></pre>



<p class="wp-block-paragraph">Only then:</p>



<pre class="wp-block-code"><code>docker volume prune</code></pre>



<p class="wp-block-paragraph">On recent Docker versions this prunes only anonymous volumes by default, with <code>--all</code> needed to include named ones — a genuinely good safety change. Older versions removed named volumes too. Check which behaviour you have before trusting it:</p>



<pre class="wp-block-code"><code>docker version --format '{{.Server.Version}}'
docker volume prune --help</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">The gigabytes Docker never tells you about</h2>



<p class="wp-block-paragraph">Here is the part that catches experienced people. Container logs do not appear in <code>docker system df</code> at all, and no prune command touches them. A chatty application with the default logging driver will happily write tens of gigabytes and nothing in Docker&#8217;s own tooling will mention it.</p>



<pre class="wp-block-code"><code>sudo du -sh /var/lib/docker/containers/*/*-json.log | sort -h | tail -10</code></pre>



<p class="wp-block-paragraph">To map a log file back to the container that owns it:</p>



<pre class="wp-block-code"><code>docker ps -aq | xargs docker inspect --format '{{.Name}} {{.LogPath}}'</code></pre>



<h3 class="wp-block-heading">Truncate, do not delete</h3>



<p class="wp-block-paragraph">This matters. Deleting a log file that a running container still has open removes the directory entry but not the data — the kernel keeps the blocks allocated until the process closes the descriptor, so <code>df</code> shows no improvement and you are left confused. Truncate instead:</p>



<pre class="wp-block-code"><code>sudo truncate -s 0 /var/lib/docker/containers/&lt;container-id&gt;/&lt;container-id&gt;-json.log</code></pre>



<h3 class="wp-block-heading">Then stop it happening again</h3>



<p class="wp-block-paragraph">Set a global default in <code>/etc/docker/daemon.json</code>:</p>



<pre class="wp-block-code"><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  }
}</code></pre>



<pre class="wp-block-code"><code>sudo systemctl restart docker</code></pre>



<p class="wp-block-paragraph">One thing people get wrong here: this applies to <strong>newly created containers only</strong>. Anything already running keeps the logging config it was created with. You have to recreate those containers for the limit to take effect — restarting them is not enough.</p>



<p class="wp-block-paragraph">While you are in that file, cap the build cache too:</p>



<pre class="wp-block-code"><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "builder": {
    "gc": {
      "enabled": true,
      "defaultKeepStorage": "20GB"
    }
  }
}</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Automating it without automating a disaster</h2>



<p class="wp-block-paragraph">A weekly cron is worth having, provided it only ever does the safe operations. The rule is simple: never put <code>--volumes</code> in anything unattended.</p>



<pre class="wp-block-code"><code># /etc/cron.d/docker-prune
# Weekly, Sunday 03:00. Containers, networks, dangling images and build
# cache older than seven days. No volumes, ever.
0 3 * * 0 root /usr/bin/docker system prune -f --filter "until=168h" &gt;&gt; /var/log/docker-prune.log 2&gt;&amp;1</code></pre>



<p class="wp-block-paragraph">Note what <code>docker system prune</code> covers without flags: stopped containers, unused networks, dangling images and build cache. It does <em>not</em> touch volumes unless you explicitly pass <code>--volumes</code>, and it does not remove tagged images unless you pass <code>-a</code>. Those two flags are the whole difference between a routine job and an incident.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">If you are tempted to add <code>--volumes</code> to a cron job so you do not have to think about it again, you have swapped a disk space problem for a data loss problem on a timer.</p>
</blockquote>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">Pruned everything, df has not moved</h3>



<p class="wp-block-paragraph">Deleted files still held open by a running process. Find them:</p>



<pre class="wp-block-code"><code>sudo lsof +L1 | head -20</code></pre>



<p class="wp-block-paragraph">Restarting the process that holds the descriptor releases the blocks. If it is the Docker daemon itself, a <code>systemctl restart docker</code> does it, at the cost of bouncing every container without a restart policy.</p>



<h3 class="wp-block-heading">&#8220;No space left on device&#8221; but df shows free space</h3>



<p class="wp-block-paragraph">You are out of inodes rather than bytes. Docker&#8217;s layered storage creates enormous numbers of small files:</p>



<pre class="wp-block-code"><code>df -i</code></pre>



<p class="wp-block-paragraph">The fix is the same — prune images and build cache, which is where the file count lives.</p>



<h3 class="wp-block-heading">overlay2 is huge and I have hardly any images</h3>



<pre class="wp-block-code"><code>sudo du -sh /var/lib/docker/*
sudo du -sh /var/lib/docker/overlay2 2&gt;/dev/null</code></pre>



<p class="wp-block-paragraph">Usually build cache or container writable layers rather than images. Do not delete anything under <code>overlay2</code> by hand — the directory names are referenced in Docker&#8217;s metadata, and removing them directly corrupts the daemon&#8217;s view of the world. Always go through <code>docker</code> commands.</p>



<h3 class="wp-block-heading">Running low and need space right now</h3>



<p class="wp-block-paragraph">Fastest safe sequence, in order:</p>



<pre class="wp-block-code"><code>docker builder prune -a -f
docker container prune -f
docker image prune -f
sudo truncate -s 0 /var/lib/docker/containers/*/*-json.log</code></pre>



<p class="wp-block-paragraph">Four commands, no volumes touched, no tagged images removed. That resolves the large majority of Docker disk usage emergencies.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Reaching for <code>docker system prune -a --volumes</code> first because it is the top search result.</li>

<li>Putting <code>--volumes</code> in a cron job.</li>

<li>Deleting log files instead of truncating them, then wondering why <code>df</code> did not change.</li>

<li>Setting log limits in <code>daemon.json</code> and assuming existing containers pick them up. They do not.</li>

<li>Removing directories under <code>/var/lib/docker/overlay2</code> by hand.</li>

<li>Storing application data in a container&#8217;s writable layer rather than a volume, so cleanup destroys it.</li>

<li>Never running <code>docker system df</code>, and so deleting the wrong category entirely.</li>

<li>Assuming inodes cannot be the problem.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Set log rotation in <code>daemon.json</code> on every host, on day one.</li>

<li>Cap build cache with the builder GC settings rather than relying on manual pruning.</li>

<li>Use named volumes for anything you care about, so cleanup and data are clearly separated.</li>

<li>Use multi-stage builds and a real <code>.dockerignore</code>. Smaller images mean less to clean up.</li>

<li>Automate only the safe operations, with an age filter, and log what the job removed.</li>

<li>Alert on disk usage at 80% so cleanup is a scheduled task rather than an incident.</li>

<li>On busy build hosts, give <code>/var/lib/docker</code> its own partition so a runaway cache cannot take the OS down with it.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Is <code>docker system prune</code> safe to run?</h3>



<p class="wp-block-paragraph">Without flags, yes, on most systems. It removes stopped containers, unused networks, dangling images and build cache. Adding <code>-a</code> also removes tagged images with no container attached. Adding <code>--volumes</code> is the one that can lose data.</p>



<h3 class="wp-block-heading">Will pruning delete my database?</h3>



<p class="wp-block-paragraph">Only if the database lives in a volume and you pass <code>--volumes</code>, or it lives in a container&#8217;s writable layer and you prune that container. If it is in a named volume attached to a running container, ordinary pruning will not touch it.</p>



<h3 class="wp-block-heading">Why does <code>docker system df</code> not match <code>du</code>?</h3>



<p class="wp-block-paragraph">Two reasons. It does not count container logs at all, and it accounts for shared image layers once rather than per image. <code>du -sh /var/lib/docker</code> is the honest total.</p>



<h3 class="wp-block-heading">How do I stop logs growing without limit?</h3>



<p class="wp-block-paragraph">Set <code>max-size</code> and <code>max-file</code> under <code>log-opts</code> in <code>/etc/docker/daemon.json</code>, restart the daemon, then recreate existing containers so they pick up the new configuration.</p>



<h3 class="wp-block-heading">Can I move Docker&#8217;s storage to another disk?</h3>



<p class="wp-block-paragraph">Yes, with <code>data-root</code> in <code>daemon.json</code>. Stop Docker, copy <code>/var/lib/docker</code> across preserving ownership and extended attributes, point <code>data-root</code> at the new location, then start it again. Copy rather than move until you have confirmed it works.</p>



<h3 class="wp-block-heading">Does pruning affect running containers?</h3>



<p class="wp-block-paragraph">No. Prune operations skip anything in use. The risk is always to things that are stopped, dangling or unattached — which is exactly why checking what is stopped before you prune is worth the thirty seconds.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">Docker disk usage is four categories that grow independently, plus a fifth — logs — that hides from Docker&#8217;s own accounting. Run <code>docker system df</code>, work down from build cache to volumes, and stop as soon as you have the space. The nuclear option exists, but reaching for it first is how people discover which of their volumes mattered.</p>



<p class="wp-block-paragraph">Once the immediate problem is solved, spend ten minutes on log rotation and builder GC. Those two settings turn this from a recurring emergency into something you never think about again.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Need a hand with Docker in production?</h2>



<p class="wp-block-paragraph">I work with teams on container infrastructure — image sizes that got out of hand, build pipelines that cache badly, storage and logging defaults that were never set, and monitoring that catches a full disk before the alert does.</p>



<p class="wp-block-paragraph">If you would rather not spend the evening on it, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-disk-usage-cleanup/">Cleaning Up Docker Disk Usage Safely</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Prometheus + Grafana From Scratch on One Server</title>
		<link>https://john-nessime.com/blog/devops/prometheus-grafana-one-server/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Thu, 30 Jul 2026 15:23:39 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Alerting]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Systemd]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=39</guid>

					<description><![CDATA[<p>Two static binaries, a package and four systemd units. How to install Prometheus and Grafana on a single server from scratch, wire in node_exporter, put nginx and TLS in front, and keep the ports that have no authentication off the public internet.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">You want to know when the disk fills up before a customer tells you. Search for how to do that and you get pointed at a hosted platform with per-host pricing, or a twelve-service Docker Compose file that somebody copy-pasted from a blog and does not fully understand either.</p>



<p class="wp-block-paragraph">For a single server, neither is the right answer. Prometheus and Grafana are two static Go binaries and a package. They run happily under systemd, use very little memory at this scale, and when something breaks you can read the logs and actually fix it. No container networking to debug, no orchestration layer between you and the problem.</p>



<p class="wp-block-paragraph">This walks through the whole thing: install Prometheus and Grafana from scratch, wire in node_exporter, put the lot behind nginx with TLS, and lock down the ports that should never have been public in the first place. Commands work on both Debian-family and RHEL-family systems, with the differences called out.</p>



<h2 class="wp-block-heading">What you are building</h2>



<p class="wp-block-paragraph">Four moving parts, all on one box:</p>



<ul class="wp-block-list">
<li><strong>node_exporter</strong> exposes machine metrics — CPU, memory, disk, network — on port 9100.</li>

<li><strong>Prometheus</strong> scrapes that endpoint on a schedule and stores the samples in its own time series database on port 9090.</li>

<li><strong>Grafana</strong> queries Prometheus and draws the dashboards on port 3000.</li>

<li><strong>nginx</strong> terminates TLS and is the only thing listening on a public interface.</li>
</ul>



<p class="wp-block-paragraph">The critical design decision is in that last line. Prometheus and node_exporter ship with no authentication at all. Bound to a public interface, they hand your entire infrastructure inventory to anyone who runs a port scan. Everything binds to 127.0.0.1 and nginx is the single front door.</p>



<p class="wp-block-paragraph">Resource-wise, a 2 GB VPS handles this comfortably for one server&#8217;s worth of metrics. Prometheus is the memory-hungry one, and its appetite scales with the number of active time series rather than the number of hosts.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 1: Users and directories</h2>



<p class="wp-block-paragraph">Neither service should run as root, and neither needs a login shell.</p>



<pre class="wp-block-code"><code>sudo useradd --system --no-create-home --shell /sbin/nologin prometheus
sudo useradd --system --no-create-home --shell /sbin/nologin node_exporter

sudo mkdir -p /etc/prometheus /var/lib/prometheus
sudo chown prometheus:prometheus /etc/prometheus /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">On Debian and Ubuntu the nologin shell lives at <code>/usr/sbin/nologin</code> rather than <code>/sbin/nologin</code>. Check with <code>which nologin</code> if useradd complains.</p>



<h2 class="wp-block-heading">Step 2: Install Prometheus</h2>



<p class="wp-block-paragraph">Prometheus ships as a single static binary. Rather than hardcoding a version that will be stale next month, pull the current release tag from the API:</p>



<pre class="wp-block-code"><code>PROM_VERSION=$(curl -s https://api.github.com/repos/prometheus/prometheus/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')
echo "Installing Prometheus ${PROM_VERSION}"

cd /tmp
curl -LO "https://github.com/prometheus/prometheus/releases/download/v${PROM_VERSION}/prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
tar xzf "prometheus-${PROM_VERSION}.linux-amd64.tar.gz"
cd "prometheus-${PROM_VERSION}.linux-amd64"

sudo cp prometheus promtool /usr/local/bin/
sudo chown prometheus:prometheus /usr/local/bin/prometheus /usr/local/bin/promtool</code></pre>



<p class="wp-block-paragraph">If you are on ARM, swap <code>linux-amd64</code> for <code>linux-arm64</code>.</p>



<h3 class="wp-block-heading">The configuration file</h3>



<p class="wp-block-paragraph">Write <code>/etc/prometheus/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>global:
  scrape_interval: 15s
  evaluation_interval: 15s

scrape_configs:
  - job_name: prometheus
    static_configs:
      - targets: ["127.0.0.1:9090"]

  - job_name: node
    static_configs:
      - targets: ["127.0.0.1:9100"]
        labels:
          instance: "web-01"</code></pre>



<p class="wp-block-paragraph">Set that <code>instance</code> label deliberately. It follows every metric into every dashboard and alert, and renaming it later means your historical graphs split in two.</p>



<p class="wp-block-paragraph">Validate before you start anything:</p>



<pre class="wp-block-code"><code>promtool check config /etc/prometheus/prometheus.yml</code></pre>



<h3 class="wp-block-heading">The systemd unit</h3>



<p class="wp-block-paragraph">Write <code>/etc/systemd/system/prometheus.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus
Wants=network-online.target
After=network-online.target

[Service]
User=prometheus
Group=prometheus
Type=simple
Restart=on-failure
RestartSec=5
ExecStart=/usr/local/bin/prometheus 
  --config.file=/etc/prometheus/prometheus.yml 
  --storage.tsdb.path=/var/lib/prometheus 
  --storage.tsdb.retention.time=30d 
  --storage.tsdb.retention.size=8GB 
  --web.listen-address=127.0.0.1:9090
ExecReload=/bin/kill -HUP $MAINPID

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/prometheus
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<p class="wp-block-paragraph">Two things worth noticing. <code>--web.listen-address=127.0.0.1:9090</code> is what keeps Prometheus off the public internet. And setting <em>both</em> retention flags matters — time-based retention alone will not save you if metric volume spikes, because Prometheus honours whichever limit it hits first.</p>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now prometheus
sudo systemctl status prometheus</code></pre>



<h2 class="wp-block-heading">Step 3: node_exporter</h2>



<p class="wp-block-paragraph">Same pattern — a static binary and a unit file.</p>



<pre class="wp-block-code"><code>NE_VERSION=$(curl -s https://api.github.com/repos/prometheus/node_exporter/releases/latest 
  | grep -Po '"tag_name": "vK[^"]*')

cd /tmp
curl -LO "https://github.com/prometheus/node_exporter/releases/download/v${NE_VERSION}/node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
tar xzf "node_exporter-${NE_VERSION}.linux-amd64.tar.gz"
sudo cp "node_exporter-${NE_VERSION}.linux-amd64/node_exporter" /usr/local/bin/
sudo chown node_exporter:node_exporter /usr/local/bin/node_exporter</code></pre>



<p class="wp-block-paragraph">Then <code>/etc/systemd/system/node_exporter.service</code>:</p>



<pre class="wp-block-code"><code>[Unit]
Description=Prometheus Node Exporter
Wants=network-online.target
After=network-online.target

[Service]
User=node_exporter
Group=node_exporter
Type=simple
Restart=on-failure
ExecStart=/usr/local/bin/node_exporter 
  --web.listen-address=127.0.0.1:9100

NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target</code></pre>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now node_exporter

# Confirm it is actually producing metrics
curl -s http://127.0.0.1:9100/metrics | head -20</code></pre>



<p class="wp-block-paragraph">Now open <code>http://127.0.0.1:9090/targets</code> — via an SSH tunnel, since it is not public — and both jobs should read UP.</p>



<pre class="wp-block-code"><code># From your laptop
ssh -L 9090:127.0.0.1:9090 you@your-server</code></pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Step 4: Install Grafana</h2>



<p class="wp-block-paragraph">Grafana is packaged properly, so use the official repository rather than a tarball. It gets you signed updates through your normal patching process.</p>



<p class="wp-block-paragraph"><strong>Debian and Ubuntu:</strong></p>



<pre class="wp-block-code"><code>sudo apt-get install -y apt-transport-https software-properties-common wget
sudo mkdir -p /etc/apt/keyrings/
wget -q -O - https://apt.grafana.com/gpg.key 
  | gpg --dearmor | sudo tee /etc/apt/keyrings/grafana.gpg &gt; /dev/null

echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" 
  | sudo tee /etc/apt/sources.list.d/grafana.list

sudo apt-get update
sudo apt-get install -y grafana</code></pre>



<p class="wp-block-paragraph"><strong>RHEL, AlmaLinux, Rocky:</strong></p>



<pre class="wp-block-code"><code>sudo tee /etc/yum.repos.d/grafana.repo &gt; /dev/null &lt;&lt;'EOF'
[grafana]
name=grafana
baseurl=https://rpm.grafana.com
repo_gpgcheck=1
enabled=1
gpgcheck=1
gpgkey=https://rpm.grafana.com/gpg.key
sslverify=1
sslcacert=/etc/pki/tls/certs/ca-bundle.crt
EOF

sudo dnf install -y grafana</code></pre>



<h3 class="wp-block-heading">Bind it to localhost too</h3>



<p class="wp-block-paragraph">Edit <code>/etc/grafana/grafana.ini</code>:</p>



<pre class="wp-block-code"><code>[server]
http_addr = 127.0.0.1
http_port = 3000
domain = metrics.example.com
root_url = https://metrics.example.com/

[security]
admin_user = admin
admin_password = put-something-real-here
cookie_secure = true

[users]
allow_sign_up = false</code></pre>



<p class="wp-block-paragraph">Getting <code>root_url</code> right matters more than it looks. Set it wrong and Grafana generates broken links and redirects behind the proxy, which produces a confusing half-working UI rather than a clean error.</p>



<pre class="wp-block-code"><code>sudo systemctl enable --now grafana-server</code></pre>



<h2 class="wp-block-heading">Step 5: Put nginx and TLS in front</h2>



<p class="wp-block-paragraph">This is the only piece that faces the internet.</p>



<pre class="wp-block-code"><code>server {
    listen 443 ssl;
    server_name metrics.example.com;

    ssl_certificate     /etc/letsencrypt/live/metrics.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/metrics.example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host              $host;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Grafana Live needs websockets, and breaks silently without this
    location /api/live/ {
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_set_header Host       $host;
        proxy_pass http://127.0.0.1:3000;
    }
}</code></pre>



<p class="wp-block-paragraph">Note <code>fullchain.pem</code> rather than <code>cert.pem</code>. Getting that wrong produces a site that works in your browser and fails for anything else.</p>



<p class="wp-block-paragraph">Then close the metrics ports at the firewall, belt and braces:</p>



<pre class="wp-block-code"><code># firewalld
sudo firewall-cmd --permanent --add-service=https
sudo firewall-cmd --reload

# ufw
sudo ufw allow 443/tcp

# Verify nothing unexpected is listening publicly
sudo ss -tlnp | grep -E '9090|9100|3000'</code></pre>



<p class="wp-block-paragraph">Every line of that last command should show <code>127.0.0.1</code>. If you see <code>0.0.0.0</code> or <code>*</code>, something is exposed and you should fix it before going any further.</p>



<h2 class="wp-block-heading">Step 6: Connect Grafana to Prometheus</h2>



<p class="wp-block-paragraph">You can click through the UI, but provisioning it as a file means a rebuilt server comes back identical. Create <code>/etc/grafana/provisioning/datasources/prometheus.yml</code>:</p>



<pre class="wp-block-code"><code>apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://127.0.0.1:9090
    isDefault: true</code></pre>



<p class="wp-block-paragraph">Restart Grafana, log in, and import dashboard ID <strong>1860</strong> from the Grafana dashboard library — Node Exporter Full. It is comprehensive, well maintained, and will show you more about your server than you knew was measurable.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Sizing the disk before it bites</h2>



<p class="wp-block-paragraph">Prometheus storage is predictable. Roughly:</p>



<pre class="wp-block-code"><code>disk = retention_seconds × samples_per_second × bytes_per_sample</code></pre>



<p class="wp-block-paragraph">Compressed samples land in the region of one to two bytes each. Measure your actual rate rather than guessing — run this in the Prometheus query box:</p>



<pre class="wp-block-code"><code>rate(prometheus_tsdb_head_samples_appended_total[5m])</code></pre>



<p class="wp-block-paragraph">One server with node_exporter at a 15 second interval is a small number, and 30 days fits in a few gigabytes. The number climbs fast once you add exporters, so keep <code>--storage.tsdb.retention.size</code> set as a hard ceiling. Prometheus filling the root partition takes the rest of the box down with it, which is a memorable way to discover your monitoring has no monitoring.</p>



<h2 class="wp-block-heading">Troubleshooting</h2>



<h3 class="wp-block-heading">A target shows DOWN</h3>



<p class="wp-block-paragraph">Work outward from the exporter:</p>



<pre class="wp-block-code"><code>curl -s http://127.0.0.1:9100/metrics | head
sudo systemctl status node_exporter
sudo journalctl -u prometheus -n 50 --no-pager</code></pre>



<p class="wp-block-paragraph">On RHEL-family systems with SELinux enforcing, this is frequently SELinux rather than your config. Check before you start changing things:</p>



<pre class="wp-block-code"><code>sudo ausearch -m avc -ts recent</code></pre>



<p class="wp-block-paragraph">If that shows denials, write a policy for them. Do not reach for <code>setenforce 0</code> — it fixes today&#8217;s symptom and quietly removes a layer of protection from the whole machine.</p>



<h3 class="wp-block-heading">Grafana loads but every panel says &#8220;No data&#8221;</h3>



<p class="wp-block-paragraph">Almost always the datasource URL. It must be <code>http://127.0.0.1:9090</code> from Grafana&#8217;s point of view, not your public hostname. Test the datasource from its settings page — Grafana reports the actual connection error, which is more useful than the empty panel.</p>



<h3 class="wp-block-heading">Prometheus will not start</h3>



<pre class="wp-block-code"><code>sudo journalctl -u prometheus -n 50 --no-pager
promtool check config /etc/prometheus/prometheus.yml
ls -la /var/lib/prometheus</code></pre>



<p class="wp-block-paragraph">The usual culprits are a YAML indentation error, or <code>/var/lib/prometheus</code> not being owned by the prometheus user after a manual file copy.</p>



<h3 class="wp-block-heading">Broken styling or redirect loops behind the proxy</h3>



<p class="wp-block-paragraph"><code>root_url</code> in grafana.ini does not match how you are actually reaching the site. If you are serving from a subpath, you also need <code>serve_from_sub_path = true</code>.</p>



<h2 class="wp-block-heading">Common mistakes</h2>



<ul class="wp-block-list">
<li>Leaving 9090, 9100 or 3000 on a public interface. Prometheus has no authentication whatsoever.</li>

<li>Keeping the default Grafana admin password because it prompts you to change it and you clicked past.</li>

<li>Setting no retention limits and discovering the problem when the root partition fills.</li>

<li>Dropping <code>scrape_interval</code> to 1s because more data seems better. It multiplies storage and buys you nothing at this scale.</li>

<li>Never backing up <code>/var/lib/grafana/grafana.db</code>, which holds every dashboard you have built.</li>

<li>Running everything as root because the tutorial you followed did.</li>

<li>Building dashboards but configuring no alerts, so you still find out from a user.</li>
</ul>



<h2 class="wp-block-heading">Best practices</h2>



<ul class="wp-block-list">
<li>Bind every component to localhost and expose exactly one TLS endpoint.</li>

<li>Set both time and size retention. Whichever triggers first is the one you want.</li>

<li>Provision datasources and dashboards as files, so the config lives in version control.</li>

<li>Back up the Grafana SQLite database on the same schedule as everything else that matters.</li>

<li>Add an external uptime check. Self-hosted monitoring cannot tell you that the server it lives on has gone.</li>

<li>Write a handful of alerts you will genuinely act on. Alerts nobody responds to train people to ignore alerts.</li>

<li>Use systemd&#8217;s sandboxing directives. They cost nothing and limit what a compromised exporter can reach.</li>
</ul>



<h2 class="wp-block-heading">Frequently asked questions</h2>



<h3 class="wp-block-heading">Should I use Docker instead?</h3>



<p class="wp-block-paragraph">For a single server, systemd is simpler. Two static binaries and a package, with no container networking layer between you and the logs. Containers earn their keep once you are managing several hosts or already run everything else that way.</p>



<h3 class="wp-block-heading">How much RAM does this need?</h3>



<p class="wp-block-paragraph">A 2 GB VPS is comfortable for one server. Prometheus memory tracks active time series rather than host count, so it grows when you add exporters, not when you add machines with the same exporters.</p>



<h3 class="wp-block-heading">Can I monitor more servers later?</h3>



<p class="wp-block-paragraph">Yes. Install node_exporter on each and add them as targets. The moment metrics cross a network, though, that traffic needs protecting — a VPN, an SSH tunnel, or TLS with client certificates. Do not simply open 9100 to the world.</p>



<h3 class="wp-block-heading">Where do alerts come from?</h3>



<p class="wp-block-paragraph">Two options. Grafana&#8217;s built-in alerting works from dashboard queries and is the quicker path. Alertmanager, run alongside Prometheus, is more powerful for routing, grouping and silencing. Start with Grafana&#8217;s and move if you outgrow it.</p>



<h3 class="wp-block-heading">How long should I keep metrics?</h3>



<p class="wp-block-paragraph">Thirty days covers most troubleshooting and capacity questions. Longer retention on a single box is usually a sign you want a remote-write backend rather than a bigger disk.</p>



<h3 class="wp-block-heading">Do I need node_exporter if I only care about my application?</h3>



<p class="wp-block-paragraph">Install it anyway. When the application misbehaves, the first question is whether the machine underneath it is healthy, and node_exporter is what answers that.</p>



<h2 class="wp-block-heading">Wrapping up</h2>



<p class="wp-block-paragraph">Once you install Prometheus and Grafana this way, the whole stack is four systemd units and three config files. Everything binds to localhost, nginx handles TLS, and there is no layer of abstraction between you and a problem when something breaks at an inconvenient hour.</p>



<p class="wp-block-paragraph">Get the dashboard working first, then spend an evening writing three or four alerts you will actually respond to. A dashboard tells you what happened when you go looking. An alert is what stops you needing to look.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">Want this set up properly?</h2>



<p class="wp-block-paragraph">I build monitoring stacks for teams who would rather not spend a weekend on it — Prometheus and Grafana on bare servers or in Kubernetes, exporters for the things you actually run, alert rules tuned so they mean something, and dashboards that answer real questions rather than filling a screen.</p>



<p class="wp-block-paragraph">If that sounds useful, you can hire me on Upwork.</p>



<div class="wp-block-buttons is-layout-flex wp-block-buttons-is-layout-flex">
<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="https://www.upwork.com/freelancers/~01f15a912ad84a6620" target="_blank" rel="noreferrer noopener">Work with me on Upwork</a></div>
</div>
<p>The post <a href="https://john-nessime.com/blog/devops/prometheus-grafana-one-server/">Prometheus + Grafana From Scratch on One Server</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
