<?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>Docker | John Nessime</title>
	<atom:link href="https://john-nessime.com/blog/tag/docker/feed/" rel="self" type="application/rss+xml" />
	<link>https://john-nessime.com/blog/tag/docker/</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>Docker | John Nessime</title>
	<link>https://john-nessime.com/blog/tag/docker/</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>Dockerfile Layer Caching: Why Every Build Reinstalls Everything</title>
		<link>https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sun, 18 Oct 2026 06:03:01 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Build Cache]]></category>
		<category><![CDATA[BuildKit]]></category>
		<category><![CDATA[Cache Mounts]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Dockerfile]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[Layer Caching]]></category>
		<category><![CDATA[Multi-Stage Builds]]></category>
		<category><![CDATA[Performance Tuning]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=409</guid>

					<description><![CDATA[<p>Your Dockerfile copies the lockfile before the source, and CI still reinstalls every dependency on every commit. That gap between a correctly ordered Dockerfile and a build that behaves as if it isn't comes down to five distinct failure families: ordering traps, a build context that poisons checksums, a cache that never reaches CI, cache mounts that vanish with the runner, and multi-stage export modes. Here is how invalidation actually works and how to prove which one is costing you.</p>
<p>The post <a href="https://john-nessime.com/blog/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</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 pipeline log scrolls past and there it is again. The dependency install step, running in full, on a commit that touched one template file. Nobody edited the lockfile. The Dockerfile copies the lockfile before the source, exactly the way every guide says to. And it still reinstalls everything, every single time.</p>



<p class="wp-block-paragraph">That gap, between a Dockerfile that looks correctly ordered and a build that behaves as if it isn&#8217;t, is where most Dockerfile layer caching problems live. The ordering advice everyone repeats is real, but it&#8217;s maybe a third of the story. The rest is about what actually invalidates a layer, where the cache physically lives, and whether the machine running your build can reach it at all.</p>



<p class="wp-block-paragraph">So this is organized by failure family rather than by feature. Ordering that looks right and isn&#8217;t. A build context that quietly poisons your checksums. A cache that exists but is unreachable from CI. Cache mounts that promise more than they deliver. Multi-stage layouts and the <code>COPY --link</code> argument. Then how to prove which one you&#8217;re hitting instead of guessing.</p>



<h2 class="wp-block-heading">How the builder actually decides to reuse a layer</h2>



<p class="wp-block-paragraph">Two different rules are running, and mixing them up causes half the confusion.</p>



<p class="wp-block-paragraph">For most instructions, the builder compares the instruction string against the cached layer. That is the entire check. It does not look inside the container to see what changed. <code>RUN apt-get update</code> matches a cached layer whenever the text matches, regardless of what the package mirrors are serving today.</p>



<p class="wp-block-paragraph">For <code>COPY</code>, <code>ADD</code>, and <code>RUN</code> instructions that use a bind mount, the builder computes a cache checksum from the metadata of the files involved. Modification time is deliberately excluded, which is why a fresh <code>git clone</code> on a CI runner doesn&#8217;t invalidate every copy by itself.</p>



<p class="wp-block-paragraph">Then the rule that ties it together: once a layer misses, every layer after it rebuilds. No skipping ahead, no partial reuse. One bad instruction near the top costs you the whole tail of the file.</p>



<p class="wp-block-paragraph">Three consequences catch people out. A <code>RUN</code> line that installs OS packages caches indefinitely, so rebuilding months later reproduces the original package versions and &#8220;just rebuild the image&#8221; is not a security response on its own. Build arguments do invalidate the cache, so an <code>ARG</code> carrying a commit SHA near the top of the file is expensive. And build secrets do <em>not</em>: their contents are excluded from the checksum, though their IDs and mount paths are part of it.</p>



<p class="wp-block-paragraph">One more that bites teams chasing reproducible builds. <code>WORKDIR</code> respects the <code>SOURCE_DATE_EPOCH</code> build argument. Set it to a git commit timestamp for provenance and you invalidate <code>WORKDIR</code> and everything below it on every single commit. Pin it to a fixed value if you want deterministic timestamps without paying that.</p>



<h2 class="wp-block-heading">Failure family one: ordering that looks right</h2>



<p class="wp-block-paragraph">The canonical fix is to split the copy so dependency installation sits above the source code. Cheap to write, and it works:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim
WORKDIR /app

# Manifest and lockfile only. Changes rarely.
COPY package.json package-lock.json ./
RUN npm ci

# Source. Changes constantly. Everything below rebuilds, and that is fine.
COPY . .
RUN npm run build</code></pre>



<p class="wp-block-paragraph">What breaks this in real repositories is usually not the copy order. It&#8217;s something above it that nobody thinks of as a layer.</p>



<ul class="wp-block-list">
<li><strong>An <code>ARG</code> near the top.</strong> A build arg whose value changes per build invalidates everything that follows. Declare args as low in the file as the build allows.</li>



<li><strong>Labels carrying commit metadata.</strong> <code>LABEL</code> lines that embed a revision are cheap to run but they are still layers. Put them at the very bottom.</li>



<li><strong>Wildcard copies that pick up more than you think.</strong> <code>COPY package*.json ./</code> looks tidy until a generated file starts matching the glob. Name the files explicitly.</li>



<li><strong>Monorepo manifests scattered across packages.</strong> If installing needs twelve manifest files from twelve directories, copy them individually or accept you&#8217;re copying a subtree and lean hard on <code>.dockerignore</code>.</li>
</ul>



<p class="wp-block-paragraph">The general test: walk the file top to bottom and ask, for each line, &#8220;what makes this change?&#8221; Anything answering &#8220;every commit&#8221; belongs below anything expensive.</p>



<h2 class="wp-block-heading">Failure family two: the build context poisons the checksum</h2>



<p class="wp-block-paragraph">When you run <code>COPY . .</code>, the checksum covers everything in the context that isn&#8217;t excluded. That means <code>.git</code>, <code>node_modules</code>, coverage output, local <code>.env</code> files, and whatever your last test run left behind. A colleague running the test suite before pushing is enough to change the checksum and rebuild the tail of your image.</p>



<p class="wp-block-paragraph">A <code>.dockerignore</code> file in the root of the context fixes it, and the rules apply to subdirectories too:</p>



<pre class="wp-block-code"><code>.git
node_modules
coverage
dist
*.log
.env
.env.*
Dockerfile
.dockerignore</code></pre>



<p class="wp-block-paragraph">This pays twice. Smaller contexts transfer faster to the builder, and unrelated local files stop ending up inside your published image. If you have ever found a <code>.git</code> directory or a stray credentials file in a production image, this is the line of defense that was missing.</p>



<p class="wp-block-paragraph">There&#8217;s a second option when the source is only needed to produce an artifact and shouldn&#8217;t live in the image at all. A bind mount makes the context available for the duration of one <code>RUN</code> without adding it to any layer:</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM golang:1-bookworm
WORKDIR /build
RUN --mount=type=bind,target=. go build -o /app/server ./cmd/server</code></pre>



<p class="wp-block-paragraph">Two rules keep this from surprising you. The mount is read-only by default, so the output has to be written outside the mount target, which is why <code>/app/server</code> sits outside <code>/build</code>. And mounted files are not persisted into the final image or the build cache. Only what the <code>RUN</code> produced survives.</p>



<h2 class="wp-block-heading">Failure family three: Dockerfile layer caching that never reaches CI</h2>



<p class="wp-block-paragraph">This is the invisible one, and it&#8217;s behind most &#8220;my Dockerfile is ordered correctly and CI is still slow&#8221; tickets.</p>



<p class="wp-block-paragraph">The build cache is internal to the builder instance. Not to your account, not to your registry. To the builder. Switch builders and the cache doesn&#8217;t follow. Start a fresh ephemeral runner and there is no cache at all, so every instruction runs from scratch no matter how beautifully the file is ordered. Nothing warns you. The build just takes as long as a cold build, because it is one.</p>



<p class="wp-block-paragraph">The fix is an external cache. <code>--cache-to</code> exports the cache somewhere durable, <code>--cache-from</code> imports it at the start of the next build.</p>



<pre class="wp-block-code"><code>docker buildx build 
  --cache-from type=registry,ref=registry.example.com/app:buildcache 
  --cache-to type=registry,ref=registry.example.com/app:buildcache,mode=max 
  --tag registry.example.com/app:latest 
  --push .</code></pre>



<p class="wp-block-paragraph">Four details decide whether this works or quietly does nothing:</p>



<ul class="wp-block-list">
<li><strong><code>mode=max</code> versus the default.</strong> The default exports only the layers of the resulting image. In a multi-stage build the expensive work happens in a stage that never ships, so the default exports almost nothing useful. <code>mode=max</code> includes intermediate stages. It costs storage and upload time, and it is usually the right trade.</li>



<li><strong>Use a separate ref for the cache.</strong> A different tag or repository, anything that isn&#8217;t the tag you push the image to.</li>



<li><strong>Registry compatibility.</strong> The registry cache defaults to writing an image index and not every registry accepts one, with Amazon ECR the usual casualty. Setting <code>image-manifest=true</code> alongside <code>oci-mediatypes=true</code> writes a single image manifest instead.</li>



<li><strong>The default driver is limited.</strong> The plain <code>docker</code> driver supports the inline, local, registry and GitHub Actions backends only when the containerd image store is enabled. Otherwise create a builder with the docker-container driver first.</li>
</ul>



<p class="wp-block-paragraph">On GitHub Actions the equivalent lives in the build action:</p>



<pre class="wp-block-code"><code>- uses: docker/setup-buildx-action@v4

- uses: docker/build-push-action@v7
  with:
    push: true
    tags: ghcr.io/acme/app:latest
    cache-from: type=gha
    cache-to: type=gha,mode=max</code></pre>



<p class="wp-block-paragraph">The GitHub Actions backend is convenient because it needs no credentials, but it draws from a per-repository storage budget shared with every other cache in that repo, and eviction is silent. A build that was fast last week can go cold with nothing changed in your Dockerfile. When that starts happening, move to a registry backend, or to an S3 or Azure Blob backend if you&#8217;d rather pay object storage rates than registry rates.</p>



<h2 class="wp-block-heading">Failure family four: cache mounts that don&#8217;t survive the job</h2>



<p class="wp-block-paragraph">Layer caching is all or nothing per instruction. Add one dependency, the install layer misses, and you re-download the four hundred packages you already had. Cache mounts fix exactly that: a directory that persists across builds even when the layer around it rebuilds.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM python:3-slim
WORKDIR /app
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip 
    pip install -r requirements.txt</code></pre>



<p class="wp-block-paragraph">Point the target at whatever directory that tool uses for its own cache. Go wants its module and build caches, Rust the Cargo directories, .NET the NuGet package folder. Apt is the one needing care, because it expects exclusive access to its data:</p>



<pre class="wp-block-code"><code>RUN --mount=type=cache,target=/var/cache/apt,sharing=locked 
    --mount=type=cache,target=/var/lib/apt,sharing=locked 
    apt-get update &amp;&amp; apt-get install --no-install-recommends -y gcc</code></pre>



<p class="wp-block-paragraph">Without <code>sharing=locked</code>, parallel builds write to the same apt cache simultaneously and you get an inconsistent package database, intermittently, which is a genuinely unpleasant thing to debug.</p>



<p class="wp-block-paragraph">Now the part that isn&#8217;t in most tutorials. <strong>Cache mounts live on the builder&#8217;s local storage and they are not exported by <code>--cache-to</code>.</strong> The external cache carries layers. It does not carry cache mount contents. On a fleet of ephemeral CI runners, every cache mount you add is a no-op: a fresh runner starts with an empty mount, fills it during the build, then throws the machine away.</p>



<p class="wp-block-paragraph">They&#8217;re still worth adding for local development, where they pay for themselves immediately. Making them pay off in CI needs a builder with persistent storage behind it: a self-hosted runner that keeps its builder between jobs, a long-lived buildkitd on a VPS from somewhere like Contabo or InterServer reached over the remote driver, the Kubernetes driver with a volume attached to the builder pods, or a managed build service such as Docker Build Cloud or Depot that holds persistent cache volumes for you.</p>



<p class="wp-block-paragraph">One hard rule regardless: never put something the image needs into a cache mount. Installing into <code>/app/node_modules</code> behind a cache mount produces an image that works on your machine and has no dependencies in production, because the mount contents are discarded when the instruction finishes. Cache the package manager&#8217;s download directory, not its output.</p>



<h2 class="wp-block-heading">Failure family five: multi-stage builds and the COPY &#8211;link argument</h2>



<p class="wp-block-paragraph">Multi-stage builds help caching for a reason that has nothing to do with image size. They let you put expensive, rarely-changing work in a stage whose inputs are stable, and copy only the artifact forward.</p>



<pre class="wp-block-code"><code># syntax=docker/dockerfile:1
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

FROM node:22-bookworm-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:22-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]</code></pre>



<p class="wp-block-paragraph">The catch wastes an afternoon if you hit it cold: export cache in the default mode and the <code>deps</code> stage isn&#8217;t in the export, because it isn&#8217;t part of the final image. Multi-stage plus external cache means <code>mode=max</code>, or the whole structure buys you nothing in CI.</p>



<p class="wp-block-paragraph">Then there&#8217;s <code>COPY --link</code>, which comes up in every thread on this topic and deserves an honest treatment rather than a recommendation. The case for it is real: a linked copy creates an independent snapshot layer that doesn&#8217;t depend on the filesystem beneath it, then merges on top. The layer can be reused with <code>--cache-from</code> even when earlier instructions in the same stage changed, and it enables rebasing an image onto an updated base without re-running the build.</p>



<p class="wp-block-paragraph">The case against is also real, and it&#8217;s mostly semantics you didn&#8217;t ask to change. Linked layers start from an empty filesystem, so a destination that resolved through a symlink created in an earlier layer no longer behaves the same, and ambiguous destinations resolve differently. Use explicit trailing slashes for directories. There are also reports of cache accounting misbehaving with linked layers, and a long-standing open report that the cache behavior doesn&#8217;t match the announcement.</p>



<p class="wp-block-paragraph">Where I land: worth trying on artifact copies into a final stage, where destinations are plain directories and the win is largest. I wouldn&#8217;t blanket-apply it across a repository on the strength of a blog post, mine included. Measure a warm rebuild before and after. If the numbers don&#8217;t move, you&#8217;ve added a semantic footgun for nothing.</p>



<h2 class="wp-block-heading">Troubleshooting: proving which layer missed and why</h2>



<p class="wp-block-paragraph">Stop guessing. The build output tells you, if you ask for the plain progress format:</p>



<pre class="wp-block-code"><code>docker buildx build --progress=plain --tag app:dev .</code></pre>



<ol class="wp-block-list">
<li><strong>Find the first step not marked CACHED.</strong> Everything after it is guaranteed to rebuild, so everything after it is noise. There is exactly one problem and it is that step.</li>



<li><strong>If it&#8217;s a COPY or ADD, the inputs changed.</strong> List what the context actually contains, compare against <code>.dockerignore</code>, and look for generated files or lockfiles being rewritten by a pre-build script.</li>



<li><strong>If it&#8217;s a RUN, something above it changed.</strong> Scroll up. A build arg, a label, a base image tag that moved, or <code>SOURCE_DATE_EPOCH</code> set from a commit timestamp.</li>



<li><strong>If the earliest steps miss in CI but not locally,</strong> the import didn&#8217;t happen. Check the log for the cache manifest being imported. A missing or unreadable cache ref usually isn&#8217;t a hard failure, the build just proceeds cold.</li>



<li><strong>If it&#8217;s intermittent, suspect eviction.</strong> Garbage collection or a cache storage budget removed what you were relying on.</li>
</ol>



<p class="wp-block-paragraph">Two commands earn their place here. The first shows what the builder is currently holding, the second clears it so you can measure a genuine cold build:</p>



<pre class="wp-block-code"><code>docker buildx du
docker buildx prune --all</code></pre>



<p class="wp-block-paragraph">And when you want one stage to run fresh while keeping the rest cached, target it by name instead of nuking everything:</p>



<pre class="wp-block-code"><code>docker build --no-cache-filter deps --tag app:dev .</code></pre>



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



<ul class="wp-block-list">
<li><strong>Assuming the Dockerfile is the whole problem.</strong> If CI has no external cache configured, no amount of reordering helps. Check that first.</li>



<li><strong>Adding cache mounts to fix CI.</strong> Great locally, invisible in the pipeline unless the builder persists.</li>



<li><strong>Exporting cache in the default mode with a multi-stage build.</strong> The expensive stage isn&#8217;t in the export. This is the most common reason external caching looks configured and doesn&#8217;t help.</li>



<li><strong>Squashing everything into one giant RUN.</strong> Fewer layers is a size optimization, not a caching one. Collapsing distinct steps means any change reruns all of them.</li>



<li><strong>Treating a cached rebuild as a patched rebuild.</strong> A cached package install gives you the versions from the original build. If you rebuilt for a CVE, you need to break that cache on purpose.</li>



<li><strong>Using <code>latest</code> as a base tag.</strong> The digest moves under you, invalidating everything below unpredictably and making builds non-reproducible at the same time.</li>
</ul>



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



<ul class="wp-block-list">
<li>Order instructions by how often their inputs change, not by what feels logical. Stable and expensive at the top, volatile and cheap at the bottom.</li>



<li>Write the <code>.dockerignore</code> before the Dockerfile. Thirty seconds of work that prevents both cache churn and accidental secret inclusion.</li>



<li>Configure an external cache in CI on day one, with <code>mode=max</code> if the build has more than one stage.</li>



<li>Add cache mounts for every package manager in the build. One line each, and the difference between &#8220;added one dependency&#8221; and &#8220;re-downloaded the world&#8221;.</li>



<li>Track warm build duration as a metric rather than a vibe. A Grafana panel showing build time per branch turns &#8220;the build feels slow&#8221; into something you can point at.</li>



<li>Keep a scheduled no-cache build in the pipeline. It proves the Dockerfile still works from scratch and picks up base image updates that cached builds silently skip.</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">Why does my Docker build ignore the cache even though nothing changed?</h3>



<p class="wp-block-paragraph">Usually because something did change and it wasn&#8217;t the file you were watching. Build with <code>--progress=plain</code> and find the first step not marked CACHED. If it&#8217;s a COPY, a gap in <code>.dockerignore</code> is letting local artifacts into the context. If it&#8217;s early in the file, check build args, labels carrying commit metadata, or a base image tag that moved. In CI, the usual answer is that there was no cache to hit at all.</p>



<h3 class="wp-block-heading">Do fewer layers make Dockerfile layer caching better?</h3>



<p class="wp-block-paragraph">No, and the advice is often applied backwards. Combining commands into one <code>RUN</code> reduces image size and stops intermediate junk being committed into separate layers. It also means any change reruns all of it. Combine steps that genuinely belong together, like an update-and-install pair, and keep independent work in separate instructions.</p>



<h3 class="wp-block-heading">Do cache mounts work in GitHub Actions?</h3>



<p class="wp-block-paragraph">Not usefully on the default hosted runners. Cache mounts live on the builder&#8217;s local storage and are not exported by <code>--cache-to</code>, so an ephemeral runner starts with an empty mount every time. You need a builder with persistent storage: a self-hosted runner keeping its builder between jobs, a remote buildkitd on your own host, the Kubernetes driver with a volume, or a managed build service.</p>



<h3 class="wp-block-heading">Registry cache backend or the GitHub Actions one?</h3>



<p class="wp-block-paragraph">Start with the GitHub Actions backend because it needs no credentials and no extra infrastructure. Move to a registry backend when you outgrow the repository cache budget, when you want the cache shared across repositories or with local developer builds, or when silent evictions make build times unpredictable. Registry caching costs a round trip and some storage in exchange for being yours.</p>



<h3 class="wp-block-heading">Does changing a build secret invalidate the cache?</h3>



<p class="wp-block-paragraph">No. Secret contents are deliberately excluded from the cache checksum, so rotating a token leaves every layer valid. The secret&#8217;s ID and mount path do participate, so renaming one invalidates. If a changed secret needs to force a rebuild, pair it with a build argument you also change, since build args do invalidate.</p>



<h3 class="wp-block-heading">Why does a cached image still contain old, vulnerable packages?</h3>



<p class="wp-block-paragraph">Because a <code>RUN</code> instruction is matched on its text, not on what it would produce today. The package install layer stays valid indefinitely, so rebuilding without breaking that cache reproduces the original package set. Force it with <code>--no-cache</code>, target the relevant stage with <code>--no-cache-filter</code>, or move the base image tag so the layers below invalidate.</p>



<h3 class="wp-block-heading">Is COPY &#8211;link safe to use everywhere?</h3>



<p class="wp-block-paragraph">Not everywhere. It changes destination semantics because linked layers start from an empty filesystem, so anything relying on symlinks created by earlier layers can break and ambiguous destinations resolve differently. It&#8217;s most defensible on artifact copies into a final stage with explicit directory paths. Try it, measure a warm rebuild, keep it only if the numbers justify the change.</p>



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



<p class="wp-block-paragraph">Dockerfile layer caching fails in two categories that need completely different fixes. Either a layer is being invalidated when it shouldn&#8217;t be, which is a Dockerfile and build context problem, or the cache was never there to begin with, which is a builder and pipeline problem. Reordering instructions solves the first and does nothing at all for the second.</p>



<p class="wp-block-paragraph">So before you spend an afternoon rearranging COPY lines, run one build with plain progress output and find the first step that isn&#8217;t CACHED. If it&#8217;s step one, your problem isn&#8217;t the Dockerfile. It&#8217;s that the machine running the build has never seen your cache and nobody told it where to look.</p>



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



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



<p class="wp-block-paragraph">Slow container builds are usually three or four small problems stacked, not one big one, and they unpick quickly once you can see the whole pipeline. I help teams with:</p>



<ul class="wp-block-list">
<li>Auditing a Dockerfile end to end for invalidation traps, oversized build contexts and stages that never cache</li>



<li>Setting up external cache backends in GitHub Actions, GitLab CI or Jenkins, including registry and object storage backends and the awkward ECR manifest cases</li>



<li>Standing up a persistent remote buildkitd on your own infrastructure so cache mounts actually pay off in CI</li>



<li>Restructuring multi-stage builds so the expensive stages are cacheable and the runtime image stays small</li>



<li>Adding build duration and cache hit visibility to your existing dashboards so regressions get caught early</li>



<li>Separating &#8220;rebuild for a code change&#8221; from &#8220;rebuild for a security patch&#8221; so cached layers stop hiding unpatched packages</li>
</ul>



<p class="wp-block-paragraph">Send me your Dockerfile and a build log with <code>--progress=plain</code> output, and I&#8217;ll tell you which step is costing you and why.</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/technical-guides/dockerfile-layer-caching/">Dockerfile Layer Caching: Why Every Build Reinstalls Everything</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</title>
		<link>https://john-nessime.com/blog/devops/docker-log-rotation/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 10 Aug 2026 18:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Container Runtime]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Disk Space]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[journald]]></category>
		<category><![CDATA[Log Retention]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[rsyslog]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Storage]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Syslog]]></category>
		<category><![CDATA[Systemd]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=182</guid>

					<description><![CDATA[<p>Docker's default logging driver writes container output to a JSON file with no size limit and no rotation. This is a practical guide to Docker log rotation: what the defaults actually do, why your daemon.json change did nothing, why deleting the log file did not free any disk, and how to choose between json-file, local, journald and shipping logs off the box.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Monitoring says the API is healthy. The ticket says nobody can deploy. You SSH in, run <code>df -h</code>, and the root filesystem is at 100%. A few minutes of poking around <code>/var/lib/docker</code> later you find it: one container, one file, a double-digit number of gigabytes of newline-delimited JSON, written continuously since the day that container was created.</p>



<p class="wp-block-paragraph">Nothing was broken. That is the part people get stuck on. The container did exactly what it was told, the daemon did exactly what it was told, and the disk filled anyway. Docker&#8217;s default logging driver has no size cap and no rotation, and it has been that way for a long time on purpose.</p>



<p class="wp-block-paragraph">This post covers Docker log rotation properly: what the defaults actually do, why the <code>daemon.json</code> change you already made did nothing, why deleting the log file did not give you your disk back, how the available drivers differ in the ways that matter under load, and what to do when you need space in the next sixty seconds.</p>



<h2 class="wp-block-heading">The default is unbounded, and that is documented behaviour</h2>



<p class="wp-block-paragraph">Docker&#8217;s default logging driver is <code>json-file</code>. It captures stdout and stderr from the container&#8217;s main process and appends one JSON object per line to a file on the host, one file per container. Each line carries the message, whether it came from stdout or stderr, and a timestamp.</p>



<p class="wp-block-paragraph">The important part is the option defaults. For <code>json-file</code>, <code>max-size</code> defaults to unlimited, <code>max-file</code> defaults to 1, and <code>compress</code> defaults to false. Put together, that means: one file, no rotation, grows until something else breaks.</p>



<p class="wp-block-paragraph">Docker&#8217;s own documentation is upfront about why. Keeping <code>json-file</code> unrotated by default preserves backwards compatibility with older engines and suits situations where Docker is acting as a runtime under Kubernetes, where the kubelet handles rotation itself. It is a deliberate choice that happens to be the wrong one for almost every standalone host.</p>



<p class="wp-block-paragraph">Before you change anything, find out what you actually have. These three commands answer different questions and you want all three:</p>



<pre class="wp-block-code"><code># What driver is the daemon handing to newly created containers?
docker info --format '{{.LoggingDriver}}'

# What is this specific container actually using right now?
docker inspect --format '{{.HostConfig.LogConfig}}' my-api

# Where does its log live, and how big has it got?
docker inspect --format '{{.LogPath}}' my-api
sudo du -h "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">The gap between the first command and the second is where most of the pain lives, and we will come back to it.</p>



<p class="wp-block-paragraph">To find the offenders across the whole host, look at the per-container directories under the Docker data root. Each one holds that container&#8217;s log file plus a small amount of metadata, so the sizes are close enough to be useful:</p>



<pre class="wp-block-code"><code>sudo du -sh /var/lib/docker/containers/* | sort -rh | head -10</code></pre>



<p class="wp-block-paragraph">Container IDs are not memorable, so map the winner back to a name with <code>docker ps --no-trunc</code> or by grepping the ID against <code>docker ps -aq</code>. In my experience the top entry is almost never the application you were worried about. It is usually a reverse proxy logging every request, a health-check loop firing every few seconds, or a debug flag somebody set during an incident and never turned off.</p>



<h2 class="wp-block-heading">Setting up Docker log rotation on the daemon</h2>



<p class="wp-block-paragraph">The daemon-wide fix goes in <code>/etc/docker/daemon.json</code>. If the file does not exist, create it. If it does, merge these keys in rather than overwriting it, because that file often already carries storage driver, address pool or registry mirror settings you do not want to lose.</p>



<pre class="wp-block-code"><code>{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3",
    "compress": "true"
  }
}</code></pre>



<p class="wp-block-paragraph">What each key buys you:</p>



<ul class="wp-block-list">
<li><code>max-size</code> caps a single file. Accepts an integer plus <code>k</code>, <code>m</code> or <code>g</code>. This is the setting that stops the runaway.</li>

<li><code>max-file</code> caps how many files are kept. When rotation would create one too many, the oldest is deleted. It is <em>only effective if <code>max-size</code> is also set</em>, which is the single most common misconfiguration I see.</li>

<li><code>compress</code> gzips rotated files. The active file stays uncompressed so it can still be appended to and read. Log text compresses extremely well, so this is close to free retention.</li>
</ul>



<p class="wp-block-paragraph">Two things trip people up here. First, every value in <code>log-opts</code> must be a JSON string, including the numeric ones. Writing <code>"max-file": 3</code> without quotes is invalid and the daemon will refuse to start cleanly. Second, validate before you restart, because a malformed <code>daemon.json</code> on a production host is a much worse afternoon than a full disk:</p>



<pre class="wp-block-code"><code># Confirm the file is valid JSON before touching the daemon
sudo python3 -c 'import json,sys; json.load(open("/etc/docker/daemon.json"))' 
  &amp;&amp; echo "daemon.json parses OK"

sudo systemctl restart docker</code></pre>



<p class="wp-block-paragraph">Now do the arithmetic, because this is a budget and not a magic switch. Worst case per container is <code>max-size</code> multiplied by <code>max-file</code>. At 10m and 3, that is roughly 30 MB uncompressed per container, less once compression kicks in on the rotated files. Multiply by your container count and compare against the disk. On a small VPS plan from a provider like InterServer or Hetzner, where the whole root volume might be 20 to 40 GB shared with images and volumes, forty containers at 30 MB each is a real slice of your budget rather than a rounding error.</p>



<h2 class="wp-block-heading">Why your daemon.json change did nothing</h2>



<p class="wp-block-paragraph">This is the failure mode that costs the most time, because it looks exactly like the fix not working.</p>



<p class="wp-block-paragraph">Logging configuration is baked into a container&#8217;s host config when the container is <em>created</em>. Restarting the daemon does not rewrite it. Restarting the container does not rewrite it either, because <code>docker restart</code> stops and starts the same container object rather than making a new one. Your new limits apply to containers created after the daemon restart and to nothing else.</p>



<p class="wp-block-paragraph">So the container that filled your disk keeps filling your disk, its log file still has no cap, and <code>docker info</code> cheerfully reports the new defaults. That mismatch is exactly what the second command in the earlier block is for.</p>



<p class="wp-block-paragraph">To actually apply it, recreate the container and then verify rather than assuming:</p>



<pre class="wp-block-code"><code># Compose: recreate the service so it picks up the new daemon defaults
docker compose up -d --force-recreate web

# Verify the container is really carrying the limits
docker inspect --format '{{.HostConfig.LogConfig}}' web</code></pre>



<p class="wp-block-paragraph">You want to see the driver name followed by the map of options. An empty map means the container is still unbounded no matter what the daemon says.</p>



<p class="wp-block-paragraph">Note that recreating a container discards its existing log history along with the old container. If those logs matter for an ongoing investigation, copy them off first.</p>



<h3 class="wp-block-heading">Pinning limits per service in Compose</h3>



<p class="wp-block-paragraph">Daemon defaults are a floor, not a policy. A chatty ingress proxy and a quiet cron worker do not deserve the same budget, and per-service configuration in Compose overrides <code>daemon.json</code> entirely. A YAML anchor keeps it from turning into copy-paste sprawl:</p>



<pre class="wp-block-code"><code>x-logging: &amp;default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"
    compress: "true"

services:
  web:
    image: nginx:alpine
    logging: *default-logging

  worker:
    image: myorg/worker:latest
    logging:
      driver: json-file
      options:
        max-size: "50m"
        max-file: "3"
        compress: "true"</code></pre>



<p class="wp-block-paragraph">Be aware that the <code>logging</code> block replaces the daemon config rather than merging with it. If you specify a driver and forget the options, you get that driver with <em>its</em> defaults, which for <code>json-file</code> means straight back to unbounded. Half-specifying is worse than not specifying at all.</p>



<h2 class="wp-block-heading">You deleted the log file and df did not move</h2>



<p class="wp-block-paragraph">Classic 3am mistake, and it is worth understanding rather than memorising.</p>



<p class="wp-block-paragraph">When you <code>rm</code> the JSON log of a running container, you remove the directory entry. You do not remove the inode, because the Docker daemon still holds an open file descriptor pointing at it. The kernel keeps the data allocated until the last descriptor closes. So the file disappears from <code>ls</code>, the daemon keeps writing into a file you can no longer see, and <code>df</code> reports exactly the same usage as before. Space comes back only when you restart the daemon or recreate the container.</p>



<p class="wp-block-paragraph"><code>truncate -s 0</code> is the right tool. It zeroes the file in place without unlinking it, so the descriptor stays valid, the daemon keeps appending to the same inode, and the blocks are released immediately:</p>



<pre class="wp-block-code"><code>sudo truncate -s 0 "$(docker inspect --format '{{.LogPath}}' my-api)"</code></pre>



<p class="wp-block-paragraph">There is a cost. Truncating out from under the daemon can leave an in-flight <code>docker logs -f</code> stream reading from an offset that no longer exists, so the follow appears to hang with no new output until you reattach. It is a known behaviour rather than corruption, and reattaching clears it. Live with it during an incident, but do not build it into a cron job.</p>



<p class="wp-block-paragraph">The same reasoning applies to <code>logrotate</code> rules pointed at <code>/var/lib/docker/containers/*/*.log</code>. Rotating with <code>copytruncate</code> works mechanically, but you are reaching into files the daemon considers its own, and you inherit the follow-stream issue on every rotation cycle. Docker&#8217;s own docs warn against external tools touching those files. If the driver can rotate for you, let it.</p>



<h2 class="wp-block-heading">Choosing a driver, and what each one costs</h2>



<h3 class="wp-block-heading">json-file</h3>



<p class="wp-block-paragraph">The default. Widest tooling compatibility, since every log shipper and every scraper knows this format. <code>docker logs</code> works natively. Its genuine downside is the format itself: JSON per line is verbose, so you pay in disk and in parse cost on read. Reach for it when something downstream reads those files directly.</p>



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



<p class="wp-block-paragraph">Docker&#8217;s recommendation for standalone hosts, and the one I reach for first. It uses a more efficient on-disk format, and critically it <em>rotates and compresses by default</em>, retaining five files of 20 MB each per container out of the box. <code>docker logs</code> works exactly as normal. The trade-off is that the format is Docker&#8217;s own, so third-party agents that expect to tail JSON files cannot read it directly. If your entire log path is <code>docker logs</code> plus a shipper that talks to the daemon, this is the safest default you can pick.</p>



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



<p class="wp-block-paragraph">Hands container output to systemd&#8217;s journal, which means you get <code>journalctl</code> filtering, structured fields, and the journal&#8217;s own size management via <code>SystemMaxUse</code> in <code>journald.conf</code>. This is a good fit on hosts where you already read system logs that way and want one retention policy instead of two.</p>



<p class="wp-block-paragraph">The catch nobody mentions: the journal has rate limiting on by default. A container in a crash loop can trip it, and messages get dropped with a note saying how many were suppressed. That is fine for noise and terrible for the incident you are actually debugging. If you go this route, look at <code>RateLimitIntervalSec</code> and <code>RateLimitBurst</code> before you need them, not after.</p>



<h3 class="wp-block-heading">syslog, fluentd and the remote drivers</h3>



<p class="wp-block-paragraph">These push logs off the box to rsyslog, Fluentd, Loki, or a hosted platform. Disk pressure genuinely goes away because the host stops being the system of record. That is the right destination for anything beyond a handful of hosts.</p>



<p class="wp-block-paragraph">What you buy with it is a dependency. Your logging path now has a network hop, and the failure modes of that hop become your failure modes. Whether it is self-hosted Grafana Loki, or a managed service like Grafana Cloud, Better Stack or Datadog, the operational question is the same: what happens to the container when the collector is unreachable? Answer that during setup rather than during an outage.</p>



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



<p class="wp-block-paragraph">Discards output entirely. Legitimate for a container that already writes its own logs to a mounted volume, and a trap everywhere else, because <code>docker logs</code> returns nothing and you will waste twenty minutes concluding the container is broken.</p>



<h3 class="wp-block-heading">The blocking behaviour worth knowing about</h3>



<p class="wp-block-paragraph">By default the logging path is blocking. If the driver cannot keep up, the container&#8217;s write to stdout blocks, which means your application stalls. On a local file that is rarely an issue. Pointed at a remote collector that has gone slow, it means a logging problem becomes an application latency problem.</p>



<pre class="wp-block-code"><code>docker run -it 
  --log-opt mode=non-blocking 
  --log-opt max-buffer-size=4m 
  alpine ping 127.0.0.1</code></pre>



<p class="wp-block-paragraph">Non-blocking mode buffers in memory and drops messages once the buffer is full rather than stalling the writer. The default buffer is 1 MB. You are choosing which failure you prefer: a slow application, or missing log lines exactly when things are going wrong. There is no answer that is right everywhere, but for a user-facing service I would rather lose lines than add latency.</p>



<h3 class="wp-block-heading">Dual logging, and why docker logs still works</h3>



<p class="wp-block-paragraph">If you switch to a remote driver and find <code>docker logs</code> still returns output, that is dual logging. When the configured driver cannot serve reads, the engine keeps a local cache using the <code>local</code> driver so the command still works. That cache rotates by default, limited to five files of 20 MB each per container before compression.</p>



<p class="wp-block-paragraph">Useful to know for two reasons. It explains why disk usage does not drop to zero after you move logging off the box. And it means the local cache has its own retention that you can tune or disable separately from the driver you configured.</p>



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



<h2 class="wp-block-heading">Reclaiming space when the disk is already full</h2>



<p class="wp-block-paragraph">Order matters here. Free space first, fix the cause second, verify third.</p>



<ol class="wp-block-list">
<li>Confirm logs are actually the problem. Compare <code>du -sh /var/lib/docker/containers</code> against <code>docker system df</code>, which reports images, volumes and build cache. Images are often the real culprit and truncating logs will not help you.</li>

<li>Identify the largest log files with the <code>du</code> and <code>sort</code> command from earlier.</li>

<li>Copy anything you need out first, if the logs are part of an active investigation.</li>

<li>Truncate the offenders with <code>truncate -s 0</code>. Never <code>rm</code>.</li>

<li>Set the limits in <code>daemon.json</code>, validate the JSON, restart the daemon.</li>

<li>Recreate the containers so the limits take effect, then confirm with <code>docker inspect</code> on each one.</li>

<li>Add a disk alert. A full disk should never be discovered by a human noticing something else is broken.</li>
</ol>



<p class="wp-block-paragraph">Step seven is the one that actually stops this recurring. Rotation caps the damage, but a container that suddenly starts logging a hundred times more than usual is telling you something, and a disk-usage alert on the Docker data root is how you hear it.</p>



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



<p class="wp-block-paragraph"><strong>Rotation is configured but files still grow past the cap.</strong> The container predates the change. Check <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code> and recreate it.</p>



<p class="wp-block-paragraph"><strong>The daemon will not start after editing daemon.json.</strong> Almost always invalid JSON, or unquoted values in <code>log-opts</code>. Read the actual error with <code>journalctl -u docker --no-pager -n 50</code> rather than guessing.</p>



<p class="wp-block-paragraph"><strong>max-file is set but only one file ever appears.</strong> <code>max-file</code> does nothing without <code>max-size</code>. Set both.</p>



<p class="wp-block-paragraph"><strong>docker logs -f hangs after showing existing output.</strong> Something truncated the file underneath the daemon, usually an external <code>logrotate</code> rule. Reattach to recover, then remove the rule.</p>



<p class="wp-block-paragraph"><strong>docker logs returns nothing at all.</strong> Check the driver. <code>none</code> discards everything, and some remote drivers only serve reads through the dual-logging cache.</p>



<p class="wp-block-paragraph"><strong>Disk usage did not drop after deleting log files.</strong> The daemon still holds the descriptors. Restart the daemon to release the inodes, and use <code>truncate</code> next time.</p>



<p class="wp-block-paragraph"><strong>Logs vanish sooner than the retention you configured.</strong> On <code>journald</code>, check journal rate limiting and <code>SystemMaxUse</code>. On a remote driver in non-blocking mode, check whether the buffer is overflowing.</p>



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



<ul class="wp-block-list">
<li>Assuming a daemon restart applies the new limits to running containers. It does not.</li>

<li>Setting <code>max-file</code> without <code>max-size</code> and believing rotation is on.</li>

<li>Writing numeric values unquoted in <code>log-opts</code>.</li>

<li>Overwriting an existing <code>daemon.json</code> instead of merging, and silently dropping storage or network settings.</li>

<li>Using <code>rm</code> on a live container&#8217;s log file and concluding the disk report is broken.</li>

<li>Specifying a <code>logging.driver</code> in Compose without its <code>options</code>, which resets that service back to the driver&#8217;s own unbounded defaults.</li>

<li>Pointing host <code>logrotate</code> at the Docker containers directory when the driver could have handled it.</li>

<li>Sizing rotation per container without multiplying by container count against the actual disk.</li>
</ul>



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



<ul class="wp-block-list">
<li>Set logging limits in <code>daemon.json</code> as part of host provisioning, before any container exists. This belongs in your Ansible role or cloud-init, not in a runbook.</li>

<li>Prefer the <code>local</code> driver on standalone hosts unless something specifically needs to read the JSON files.</li>

<li>Turn on <code>compress</code>. Log text compresses well enough that you get more retention for the same disk.</li>

<li>Size per service rather than uniformly. Give the noisy proxy a tight cap and the service you actually debug a generous one.</li>

<li>Do the multiplication. Worst-case host usage is <code>max-size</code> × <code>max-file</code> × container count, and it should be a fraction of your disk, not most of it.</li>

<li>Alert on Docker data root usage at a threshold that leaves you time to act.</li>

<li>Treat on-host logs as a short buffer, not an archive. Anything you need next quarter belongs in a log platform, not on the VPS.</li>

<li>Verify with <code>docker inspect</code> after any change. <code>docker info</code> tells you the daemon&#8217;s intent, not what your containers carry.</li>
</ul>



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



<h3 class="wp-block-heading">Does Docker rotate container logs automatically?</h3>



<p class="wp-block-paragraph">Not with the default driver. <code>json-file</code> ships with <code>max-size</code> unlimited and <code>max-file</code> set to 1, so a single file grows without bound. The <code>local</code> driver does rotate and compress by default. If you have not explicitly configured Docker log rotation, assume it is off.</p>



<h3 class="wp-block-heading">Where are Docker container logs stored on the host?</h3>



<p class="wp-block-paragraph">Under the Docker data root, one directory per container, which on a standard Linux install means <code>/var/lib/docker/containers/</code>. Rather than assembling the path by hand, ask the daemon: <code>docker inspect --format '{{.LogPath}}' &lt;container&gt;</code>. That works regardless of a custom <code>data-root</code>.</p>



<h3 class="wp-block-heading">How do I clear Docker logs without stopping the container?</h3>



<p class="wp-block-paragraph">Truncate the file to zero bytes with <code>truncate -s 0</code> on the path from <code>{{.LogPath}}</code>. Do not use <code>rm</code>: the daemon holds an open descriptor, so deleting the directory entry leaves the data allocated and frees no space until the daemon restarts.</p>



<h3 class="wp-block-heading">Why did my daemon.json rotation settings not apply?</h3>



<p class="wp-block-paragraph">Because logging config is fixed at container creation time. A daemon restart changes what new containers get; it does not rewrite existing ones, and <code>docker restart</code> reuses the same container object. Recreate the container, then confirm with <code>docker inspect --format '{{.HostConfig.LogConfig}}'</code>.</p>



<h3 class="wp-block-heading">Should I use the local driver or json-file?</h3>



<p class="wp-block-paragraph">Use <code>local</code> if nothing outside Docker reads the log files directly, since it rotates and compresses by default and uses less disk for the same content. Use <code>json-file</code> if an agent or shipper on the host tails those files, because it cannot parse the <code>local</code> format. Either way <code>docker logs</code> behaves the same.</p>



<h3 class="wp-block-heading">Can I use logrotate for Docker container logs instead?</h3>



<p class="wp-block-paragraph">You can, and plenty of hosts do, but the driver options are the better tool. A <code>copytruncate</code> rule pointed at the containers directory reaches into files the daemon treats as private, and can leave <code>docker logs -f</code> streams stalled after each rotation. Use <code>logrotate</code> for logs your applications write to mounted volumes, and let the driver handle stdout.</p>



<h3 class="wp-block-heading">How much disk should I budget for container logs?</h3>



<p class="wp-block-paragraph">Worst case is <code>max-size</code> × <code>max-file</code> per container, multiplied by how many containers run on the host. At 10m and 3 files across thirty containers that is roughly 900 MB before compression. Pick numbers that leave the total at a comfortable fraction of the disk, and keep long-term retention off the box.</p>



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



<p class="wp-block-paragraph">Docker log rotation is not a feature you turn on once at the daemon and forget. It is a property each container carries from the moment it is created, and every container that existed before your change is still running under the old rules.</p>



<p class="wp-block-paragraph">So the working sequence is always the same: set the limits, restart the daemon, recreate the containers, then verify with <code>docker inspect</code> rather than trusting <code>docker info</code>. Add a disk alert on the Docker data root so the next surprise arrives as a notification and not as a failed deploy. Everything else in this post is detail around that.</p>



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



<h2 class="wp-block-heading">Need a hand with container logging on your hosts?</h2>



<p class="wp-block-paragraph">Log sprawl is one of those problems that is cheap to fix in an hour and expensive to leave alone. If you would rather have someone go through it with you, this is the kind of work I take on:</p>



<ul class="wp-block-list">
<li>Auditing every container on a host for logging driver and retention, and producing the recreate plan that actually applies the limits</li>

<li>Sizing a rotation budget against your real disk, container count and log volume, per service rather than one flat number</li>

<li>Emergency recovery on a full Docker host, including the cases where deleting files did not give the space back</li>

<li>Choosing between <code>json-file</code>, <code>local</code>, <code>journald</code> and shipping off-box, including the blocking behaviour trade-offs under load</li>

<li>Setting up centralised logging with Loki, rsyslog or a hosted platform, with sane retention on both ends</li>

<li>Baking logging limits into provisioning so new hosts are never born unbounded, plus the disk alerts to catch what rotation cannot</li>
</ul>



<p class="wp-block-paragraph">If you want a second opinion, send me your <code>daemon.json</code>, the output of <code>docker ps</code> and a <code>du -sh</code> of your containers directory, and I will tell you what I would change.</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-log-rotation/">Docker Logs Ate My Disk: A Working Guide to Log Drivers and Rotation</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>Docker Compose in Production: What Works and What Quietly Burns You</title>
		<link>https://john-nessime.com/blog/devops/docker-compose-in-production/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 07 Aug 2026 13:00:00 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Health Checks]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[Sysadmin]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Volumes]]></category>
		<category><![CDATA[VPS]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=159</guid>

					<description><![CDATA[<p>Running Docker Compose in production is a reasonable choice for a single host, but the defaults were chosen for a laptop. A walk through the failure families that actually bite: the deploy gap, unrotated logs filling the disk, anonymous volumes, published ports that bypass your firewall, secrets in environment variables, and health checks that report without acting.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-compose-in-production/">Docker Compose in Production: What Works and What Quietly Burns You</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 stack had been up for months. Nobody had touched it, nobody had needed to. Then the disk alert fires at an awkward hour, Postgres flips to read-only because it cannot write WAL, and you SSH in to find that one chatty container has written tens of gigabytes into a single JSON log file that nothing was ever going to rotate.</p>



<p class="wp-block-paragraph">That is the shape of most Docker Compose incidents. Not a dramatic architectural failure. A default that was fine on your laptop and wrong on a server, sitting quietly for half a year until it wasn&#8217;t.</p>



<p class="wp-block-paragraph">Running Docker Compose in production is a perfectly reasonable choice for a lot of workloads. The tooling is stable, the file format is readable, and a single host with a handful of services does not need a control plane. What it does need is that you go through the defaults deliberately, because Compose was designed for a developer machine and inherits assumptions from that world.</p>



<p class="wp-block-paragraph">This post walks the failure families I look for first when reviewing a production Compose setup: the deploy gap, disk exhaustion, state you did not mean to keep, published ports that walk past your firewall, secrets handling, and health checks that report without acting. Then the troubleshooting commands, the mistakes I see repeatedly, and an honest read on when to stop using Compose.</p>



<h2 class="wp-block-heading">What Docker Compose genuinely gets right</h2>



<p class="wp-block-paragraph">Worth stating the case before pulling it apart, because a lot of writing on this topic is really Kubernetes marketing.</p>



<ul class="wp-block-list">
<li><strong>The whole system fits in one file you can read.</strong> Onboarding somebody onto a Compose stack takes minutes. That is not a small thing when you are the only person on call.</li>

<li><strong>Dev and prod can share a base file.</strong> Override files let you keep one source of truth and layer the production differences on top, rather than maintaining two drifting definitions.</li>

<li><strong>No control plane to operate.</strong> A Kubernetes cluster is a system that itself needs upgrading, monitoring and debugging. On a single VPS from a provider like Hetzner, DigitalOcean or InterServer, that overhead buys you very little.</li>

<li><strong>Recovery is comprehensible.</strong> When something breaks at 2am, <code>docker compose ps</code> and <code>docker compose logs</code> tell you nearly everything. There is no scheduler making decisions you have to reverse-engineer.</li>
</ul>



<p class="wp-block-paragraph">If your workload is one machine, one team, and downtime measured in seconds rather than zero, Compose is a defensible answer. The rest of this post is about making that answer survive contact with a real server.</p>



<h2 class="wp-block-heading">Failure family one: the deploy gap</h2>



<p class="wp-block-paragraph">This is the one that surprises people who came from a platform that did rolling updates for them.</p>



<p class="wp-block-paragraph"><code>docker compose up -d</code> is not a rolling update. When a service&#8217;s image or config has changed, Compose stops the old container and then starts the new one. Between those two events the service does not exist. If the container takes twenty seconds to boot a JVM or run migrations, that is twenty seconds of connection refused.</p>



<p class="wp-block-paragraph">The <code>deploy.update_config</code> block with <code>order: start-first</code> exists in the Compose file specification, but it describes behaviour for orchestrators like Swarm. Do not assume it gives you overlap on a plain Compose host. Test it on your own setup before you rely on it.</p>



<h3 class="wp-block-heading">What to do instead</h3>



<p class="wp-block-paragraph">Three options, in increasing order of effort:</p>



<ol class="wp-block-list">
<li><strong>Accept the gap and shrink it.</strong> Pull images before you cut over so the restart is not waiting on a network transfer, and make the container boot fast. For an internal tool, a five-second gap is fine and you should not build machinery to avoid it.</li>

<li><strong>Put a reverse proxy in front and run two slots.</strong> Traefik, Caddy or plain Nginx in a container, with <code>app-blue</code> and <code>app-green</code> services. Start the new one, wait for it to pass its health check, move the proxy, stop the old one. This is real zero-downtime and it is maybe forty lines of config.</li>

<li><strong>Drain at the edge.</strong> If you already sit behind Cloudflare or a load balancer, take the host out of rotation, deploy, put it back. Simplest when you have more than one host anyway.</li>
</ol>



<p class="wp-block-paragraph">Whichever you pick, the deploy itself should pull first and then block on health rather than returning immediately:</p>



<pre class="wp-block-code"><code># Fetch new images while the old containers are still serving traffic.
docker compose pull

# Recreate changed services, then block until every service with a
# healthcheck reports healthy. Non-zero exit if something never gets there.
docker compose up -d --wait --wait-timeout 120

# --wait only knows about services that define a healthcheck.
# Services without one are treated as ready the moment they start.</code></pre>



<p class="wp-block-paragraph">That last line matters more than it looks. A service with no health check is invisible to <code>--wait</code>, so a deploy script can report success while your API is still crash-looping.</p>



<h2 class="wp-block-heading">Failure family two: the disk fills up and nothing tells you</h2>



<p class="wp-block-paragraph">Docker&#8217;s default logging driver is <code>json-file</code>, and by default it performs no rotation at all. The docs are explicit that this default exists for backward compatibility, and that the <code>local</code> driver is the recommended alternative because it rotates out of the box and uses a more compact format.</p>



<p class="wp-block-paragraph">So the failure mode is: a service starts logging every request, or starts emitting a stack trace in a loop, and the log file grows without limit until the filesystem is full. Everything else on that host dies at the same moment, which makes the root cause harder to see, not easier.</p>



<p class="wp-block-paragraph">Fix it once at the daemon level so it applies to every container on the host, including ones you spin up by hand:</p>



<pre class="wp-block-code"><code>// /etc/docker/daemon.json
{
  "log-driver": "local",
  "log-opts": {
    "max-size": "10m",
    "max-file": "3"
  },
  "default-address-pools": [
    { "base": "10.40.0.0/16", "size": 24 }
  ]
}</code></pre>



<p class="wp-block-paragraph">Two things to know before you restart the daemon. Log options must be strings in this file, quotes included, or Docker will refuse to start. And the change only affects containers created afterwards; existing containers keep whatever config they were created with, so you need to recreate them.</p>



<p class="wp-block-paragraph">You can also set limits per service, which is worth doing for a known-chatty component. A YAML anchor keeps it from being copy-pasted six times:</p>



<pre class="wp-block-code"><code>x-logging: &amp;default-logging
  logging:
    driver: local
    options:
      max-size: "10m"
      max-file: "3"

services:
  api:
    image: registry.example.com/api:1.4.2
    &lt;&lt;: *default-logging

  worker:
    image: registry.example.com/worker:1.4.2
    &lt;&lt;: *default-logging</code></pre>



<p class="wp-block-paragraph">Logs are only one of three things eating the disk. The others are old images, which accumulate every time you deploy, and dangling volumes. Put a scheduled cleanup on the host and monitor free space with whatever you already run, whether that is Prometheus and Grafana, a hosted agent, or a plain cron job piping into Healthchecks.io.</p>



<pre class="wp-block-code"><code># Images not used by any container and older than a week.
# -a includes untagged parents, not just dangling layers.
docker image prune -a --filter "until=168h" --force

# Where the space actually went.
docker system df -v</code></pre>



<h2 class="wp-block-heading">Failure family three: state you did not mean to keep</h2>



<p class="wp-block-paragraph">Two related traps here.</p>



<p class="wp-block-paragraph">The first is anonymous volumes. If an image declares a <code>VOLUME</code> in its Dockerfile and your Compose file does not map that path to a named volume, Docker creates an anonymous one. Your data is real and it is on disk, but it has a hash for a name, it is not in your backup script, and the next person who runs a cleanup command has no way to know it matters. Always name your volumes explicitly.</p>



<p class="wp-block-paragraph">The second is <code>docker compose down</code>. On its own it removes containers and the project&#8217;s networks but leaves named volumes alone, which is the behaviour you want. Add <code>-v</code> and it deletes those volumes too. There is no confirmation prompt and no undo. I have seen that flag reach production because somebody had it in a local teardown alias and pasted the alias into a runbook.</p>



<p class="wp-block-paragraph">The practical rule: on a production host, restarting services is <code>docker compose up -d</code> or <code>docker compose restart</code>. <code>down</code> belongs in a maintenance procedure with a fresh backup, not in a daily habit.</p>



<p class="wp-block-paragraph">Bind mounts deserve a mention too. They are convenient and they tie the container to a specific host path with specific host permissions. That is fine for config files you want to edit in place. For database data, a named volume gives you something Docker manages and something you can back up as a unit.</p>



<h2 class="wp-block-heading">Failure family four: ports that bypass your firewall</h2>



<p class="wp-block-paragraph">This is the one that turns into a security incident rather than an outage.</p>



<p class="wp-block-paragraph">When you write <code>ports: - "5432:5432"</code>, Docker publishes that port on all host interfaces and inserts its own rules into the kernel&#8217;s NAT table to forward traffic to the container. Those rules are evaluated before the chains that a host firewall such as ufw or firewalld typically manages. The result is a database that is reachable from the internet even though your firewall config says otherwise, and a <code>ufw status</code> output that looks perfectly reassuring.</p>



<p class="wp-block-paragraph">The fix is to stop publishing what does not need publishing:</p>



<pre class="wp-block-code"><code>services:
  db:
    image: postgres:16
    # No ports: block at all. Other services on the same Compose
    # network reach it as db:5432 by service name.
    networks: [backend]

  api:
    image: registry.example.com/api:1.4.2
    # Bound to loopback only. The reverse proxy on this host can
    # reach it; the internet cannot, regardless of firewall state.
    ports:
      - "127.0.0.1:8080:8080"
    networks: [backend, edge]</code></pre>



<p class="wp-block-paragraph">Two habits follow from this. Publish a port only when something outside the host genuinely needs it, and when you do, bind it to a specific interface. Everything else talks over the Compose network by service name. Then verify from somewhere else entirely, because checking from the host itself proves nothing.</p>



<p class="wp-block-paragraph">While you are in daemon config: set <code>default-address-pools</code> as shown earlier. Compose creates a bridge network per project from a default range, and if that range overlaps your office LAN or your VPN subnet, you get routing that fails only for some people, only sometimes. It is a miserable thing to debug and a one-line thing to prevent.</p>



<h2 class="wp-block-heading">Failure family five: secrets in environment variables</h2>



<p class="wp-block-paragraph">Environment variables are the default way to pass configuration to a container, and they are a mediocre way to pass secrets. They show up in <code>docker inspect</code>, they are readable by anything that can enumerate the process environment, they get inherited by child processes, and they land in crash dumps and error reporters.</p>



<p class="wp-block-paragraph">The Compose specification supports file-based secrets without any orchestrator. Declare them at the top level and grant them per service; they appear inside the container as files under <code>/run/secrets/</code>:</p>



<pre class="wp-block-code"><code>services:
  db:
    image: postgres:16
    environment:
      # Official Postgres, MySQL and Redis images support the _FILE
      # convention: read the value from this path instead of the env var.
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password

secrets:
  db_password:
    file: ./secrets/db_password.txt</code></pre>



<p class="wp-block-paragraph">The file still lives on the host, so this is not a vault. What it buys you is that the value is not in the container&#8217;s environment, not in <code>docker inspect</code> output, and not in your Compose file. Set the file to mode 600 owned by root and keep it out of Git.</p>



<p class="wp-block-paragraph">One more thing that trips people up: <code>.env</code> and <code>env_file</code> are different mechanisms. The <code>.env</code> file in the project directory feeds variable interpolation <em>inside</em> the Compose file. <code>env_file</code> passes variables <em>into</em> the container. Confusing them produces a service that starts fine with an empty config value and fails somewhere far from the cause.</p>



<h2 class="wp-block-heading">Failure family six: health checks that report but never act</h2>



<p class="wp-block-paragraph">A container health check marks a container healthy, unhealthy, or starting. On a plain Docker host, that status is a label. Nothing restarts an unhealthy container. The restart policy only reacts to the process exiting.</p>



<p class="wp-block-paragraph">So the classic silent failure is an app that has deadlocked, or lost its database pool, or wedged a worker thread. The process is alive, so the restart policy sees nothing to do. The health check goes red. Your monitoring, if it only checks that the container is running, sees nothing wrong. The service is down and every automated signal says it is fine.</p>



<p class="wp-block-paragraph">Health checks are still worth defining, because they gate startup ordering and they gate <code>--wait</code>. Just be clear about what they do not do.</p>



<pre class="wp-block-code"><code>services:
  db:
    image: postgres:16
    restart: unless-stopped
    healthcheck:
      # pg_isready exits non-zero until Postgres accepts connections.
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5
      # Grace period. Failures during start_period do not count
      # toward retries, so a slow first boot is not marked unhealthy.
      start_period: 30s

  api:
    image: registry.example.com/api:1.4.2
    restart: unless-stopped
    depends_on:
      db:
        # Waits for db to report healthy before creating api.
        # This is a startup-order guarantee only, not a runtime one.
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/healthz"]
      interval: 15s
      timeout: 5s
      retries: 3
      start_period: 20s</code></pre>



<p class="wp-block-paragraph">Note <code>restart: unless-stopped</code> rather than <code>always</code>. The difference shows up after a host reboot: <code>always</code> will start a container you had deliberately stopped, <code>unless-stopped</code> respects that you stopped it. If you have ever taken a service down for maintenance and found it running again after a kernel update, this is why.</p>



<p class="wp-block-paragraph">To close the gap, point an external check at the health endpoint. Whatever you already use for uptime monitoring is fine. The requirement is that something outside the host asks the application whether it is working, rather than asking Docker whether a process exists.</p>



<h2 class="wp-block-heading">Troubleshooting a Compose stack that is misbehaving</h2>



<p class="wp-block-paragraph">The commands I reach for, roughly in order:</p>



<pre class="wp-block-code"><code># The fully resolved config: overrides merged, variables substituted,
# anchors expanded. This is what Compose is actually going to run.
# Careful, it prints secret VALUES from interpolation.
docker compose config

# Health state and exit codes, not just up/down.
docker compose ps --all

# Why did it die? Look at the last lines before the restart.
docker compose logs --tail=200 --timestamps api

# The health check's own output, which is where the real error usually is.
docker inspect --format '{{json .State.Health}}' &lt;container&gt;

# Live resource use. Sudden memory growth before a restart means OOM.
docker stats --no-stream

# Did the kernel OOM-killer take it? Exit code 137 is the hint.
dmesg --ctime | grep -i "out of memory"</code></pre>



<p class="wp-block-paragraph">Two patterns worth recognising. A container that restarts every few minutes with exit code 137 was almost certainly killed for memory, either by a limit you set or by the host running out. And a service that works from inside the network but not from outside is nearly always a published-port or reverse-proxy problem, not an application problem, so check the plumbing before you read application code.</p>



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



<ul class="wp-block-list">
<li><strong>Deploying <code>:latest</code>.</strong> You cannot tell what is running, and a rebuild elsewhere silently changes what you get on the next pull. Pin a version tag, or pin a digest if you want it to be genuinely immutable.</li>

<li><strong>Keeping the <code>version:</code> key at the top of the file.</strong> Compose v2 ignores it and warns that it is obsolete. Delete it from your base file and from every override.</li>

<li><strong>No resource limits anywhere.</strong> One leaking service takes down every other service on the host. Limits turn a total outage into one restarting container.</li>

<li><strong>Running <code>docker compose up</code> from an SSH session and walking away.</strong> Without <code>-d</code> the stack is tied to your terminal. Use detached mode, and put the stack behind a systemd unit if you want it managed like other services on the box.</li>

<li><strong>The Compose file lives only on the server.</strong> If your production definition is not in Git, you have no history, no review, and no recovery when the disk dies.</li>

<li><strong>Pulling from Docker Hub anonymously in a deploy script.</strong> Anonymous pulls are rate limited per IP, so the deploy that worked all week fails on a busy afternoon. Authenticate, or mirror the images you depend on into your own registry.</li>

<li><strong>Automatic image updates on production.</strong> Tools that watch a registry and redeploy on their own are excellent for a homelab. On a system that matters, you want the update to happen when you are watching.</li>
</ul>



<h2 class="wp-block-heading">Best practices for Docker Compose in production</h2>



<ul class="wp-block-list">
<li><strong>One base file plus a production override.</strong> Keep shared service definitions in the base, and put published ports, resource limits, logging and restart policy in the override. Then <code>docker compose -f compose.yaml -f compose.prod.yaml up -d</code> is your deploy, and the diff between environments is a file you can read.</li>

<li><strong>Give every service a health check.</strong> It gates startup order, it makes <code>--wait</code> meaningful, and it gives you a status worth alerting on.</li>

<li><strong>Set memory and CPU limits on everything.</strong> Watch <code>docker stats</code> under real load first, then set the limit above observed peak with headroom. A limit set from a guess causes the outage it was meant to prevent.</li>

<li><strong>Segment your networks.</strong> An edge network for anything the proxy touches, a backend network for datastores. Only services that need to reach the database should be able to.</li>

<li><strong>Back up volumes, and restore one.</strong> A backup you have never restored is a hypothesis. Restore into a scratch stack on a schedule you actually keep.</li>

<li><strong>Make the deploy a script, not a memory.</strong> Pull, up with <code>--wait</code>, verify, prune. Six lines in the repo beats six commands somebody half-remembers.</li>

<li><strong>Run <code>docker compose config</code> in CI.</strong> It catches a malformed override before it reaches the server, and it costs nothing.</li>
</ul>



<h2 class="wp-block-heading">When to stop using Compose</h2>



<p class="wp-block-paragraph">Being straight about the limits is more useful than defending the tool. Compose is a single-host tool. The moment your requirement is &#8220;survive the loss of this machine&#8221;, you have outgrown it, and no amount of configuration closes that gap.</p>



<p class="wp-block-paragraph">The signals I treat as a real trigger, rather than as an excuse to rewrite everything:</p>



<ul class="wp-block-list">
<li>You need more than one machine for availability, not just for capacity.</li>

<li>You need automatic rescheduling when a host dies, without somebody logging in.</li>

<li>You are scaling components independently and often enough that doing it by hand is a real cost.</li>

<li>Several teams deploy to the same infrastructure and need isolation from each other.</li>
</ul>



<p class="wp-block-paragraph">If none of those are true, migrating to Kubernetes buys you a second system to operate and a longer list of ways to be paged. Plenty of profitable software runs on one well-configured box. The trade-off is real in both directions, and the honest answer depends on what your availability target actually is when written down.</p>



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



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



<h3 class="wp-block-heading">Is Docker Compose production ready?</h3>



<p class="wp-block-paragraph">For a single host, yes, provided you change the defaults that were chosen for development machines: log rotation, resource limits, port binding, named volumes, pinned image tags. Compose is not production ready in the sense of surviving a host failure, because it has no concept of a second host. That is a capability boundary, not a maturity problem.</p>



<h3 class="wp-block-heading">How do I get zero-downtime deployments with Docker Compose?</h3>



<p class="wp-block-paragraph">Not from Compose alone. Put a reverse proxy in front, run two instances of the service under different names, start the new one, wait for its health check to pass, switch the proxy, then stop the old one. Traefik does the switching automatically based on labels; Nginx or Caddy need you to reload config. If the service is not customer-facing, a few seconds of downtime is usually the cheaper answer.</p>



<h3 class="wp-block-heading">Does <code>docker compose down</code> delete my database?</h3>



<p class="wp-block-paragraph">Not by itself. Plain <code>down</code> removes containers and the project&#8217;s networks and leaves named volumes in place. Adding <code>-v</code> removes those volumes, and there is no prompt and no recovery. Anonymous volumes are the dangerous case, because you may not realise data is living in one until it is gone.</p>



<h3 class="wp-block-heading">Why does my container keep restarting with exit code 137?</h3>



<p class="wp-block-paragraph">137 means the process received SIGKILL, and in containers that almost always means it was killed for memory. Either it hit a limit you configured, or the host ran out and the kernel OOM-killer chose it. Check <code>docker stats</code> for growth over time and <code>dmesg</code> for OOM entries. Raising the limit is the fix only if the memory use is legitimate; if it grows without bound, you have a leak and a higher limit just delays the restart.</p>



<h3 class="wp-block-heading">Should I still write <code>version:</code> at the top of my Compose file?</h3>



<p class="wp-block-paragraph">No. Compose v2 validates against the current specification regardless of what that key says, and it emits a warning telling you the key is obsolete. Remove it from the base file and from every override file, otherwise the warning follows you around.</p>



<h3 class="wp-block-heading">Can I use Docker secrets without Swarm?</h3>



<p class="wp-block-paragraph">Yes, using file-based secrets. Declare a top-level <code>secrets</code> block with a <code>file:</code> source, grant it to a service, and the content appears at <code>/run/secrets/&lt;name&gt;</code> inside the container. It is not a secrets manager, since the plaintext still sits on the host, but it keeps credentials out of the environment and out of <code>docker inspect</code>.</p>



<h3 class="wp-block-heading">How many services is too many for one Compose file?</h3>



<p class="wp-block-paragraph">There is no hard limit, and the count matters less than the coupling. The signal to split is when a change to one service forces you to think about services that have nothing to do with it, or when one team&#8217;s deploy restarts another team&#8217;s containers. Compose profiles let you group optional services within a file before you commit to splitting into separate projects.</p>



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



<p class="wp-block-paragraph">Running Docker Compose in production does not fail because Compose is a toy. It fails because the defaults are development defaults, and every one of them is fine right up until the day it isn&#8217;t. Unrotated logs, unlimited memory, published ports, anonymous volumes, health checks nobody watches. None of these announce themselves. They wait.</p>



<p class="wp-block-paragraph">Go through your compose file once, deliberately, and ask of every service: where do its logs go, what is its memory ceiling, what happens to its data on <code>down</code>, who can reach its ports, and what tells you when it is unhealthy. That review takes an afternoon. It is the cheapest reliability work available to you, and it is most of the distance between a Compose stack that quietly burns you and one that just keeps running.</p>



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



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



<p class="wp-block-paragraph">I work with teams running containerised workloads on their own servers. Things I am usually brought in for:</p>



<ul class="wp-block-list">
<li>Reviewing a production Compose file and daemon config against the failure modes above, with a prioritised list of what to change first</li>

<li>Building a zero-downtime deploy path with a reverse proxy and two service slots, wired into your existing CI</li>

<li>Fixing disk exhaustion for good: log driver, rotation, image pruning, and alerting that fires before the filesystem does</li>

<li>Sorting out network segmentation and published ports so the firewall config on the host is actually the firewall</li>

<li>Volume backup and restore that has been tested by restoring, not just by running</li>

<li>An honest assessment of whether you should move to Kubernetes or stay where you are, with the reasoning written down</li>
</ul>



<p class="wp-block-paragraph">If something in your stack is behaving oddly, send me the compose file, the output of <code>docker compose ps --all</code>, or the logs from whatever restarted last night. Easier to talk about a real config than a hypothetical one.</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-compose-in-production/">Docker Compose in Production: What Works and What Quietly Burns You</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>The Cluster Becomes the Product: When Kubernetes Is Overkill</title>
		<link>https://john-nessime.com/blog/devops/when-kubernetes-is-overkill/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Mon, 03 Aug 2026 15:26:00 +0000</pubDate>
				<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Kubernetes]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Deployment]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Platform Engineering]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[VPS]]></category>
		<category><![CDATA[Web Hosting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=93</guid>

					<description><![CDATA[<p>Three hours into an ingress controller upgrade for an application that is one Go binary and a Postgres database. Nothing is broken. The cluster has just become the thing you operate. An honest look at what Kubernetes costs, when it earns that cost, and what to run instead.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/when-kubernetes-is-overkill/">The Cluster Becomes the Product: When Kubernetes Is Overkill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">It&#8217;s a Tuesday and you&#8217;re three hours into an ingress controller upgrade. The CRD schema changed, the annotations you copied from a blog post two years ago are deprecated, and cert-manager wants a newer API version than the one your Helm chart pins.</p>



<p class="wp-block-paragraph">The application behind all of this is one Go binary and a Postgres database. It serves a few hundred requests a minute. Nobody has touched its code in a fortnight.</p>



<p class="wp-block-paragraph">That is the moment worth noticing: <strong>when Kubernetes is overkill</strong>, the tell is not that anything is broken. Everything works. It is that the cluster has quietly become the thing you operate, and the product has become a tenant.</p>



<h2 class="wp-block-heading">What it actually buys you</h2>



<p class="wp-block-paragraph">Worth being fair, because the case for Kubernetes is real and I reach for it regularly.</p>



<p class="wp-block-paragraph">It gives you bin-packing across many workloads, so a fleet of services shares machines efficiently. It gives you a declarative API that a platform team can build on, so twelve product teams deploy without twelve bespoke pipelines. It gives you self-healing, rolling updates, horizontal autoscaling and namespace-level isolation as defaults rather than as things you script. And it gives you a portable vocabulary: an engineer who knows Kubernetes knows most of your infrastructure on day one.</p>



<p class="wp-block-paragraph">Every one of those benefits scales with the number of services and the number of teams. None of them scale with how modern you want to feel.</p>



<h2 class="wp-block-heading">The bill nobody itemises</h2>



<p class="wp-block-paragraph">The cost is not the control plane fee. It is the surface area you have agreed to keep current.</p>



<p class="wp-block-paragraph">Kubernetes ships three minor releases a year and supports each for about fourteen months, and you can skip at most one minor version when upgrading. So there is a permanent, non-negotiable upgrade cadence: roughly every four months something needs planning, testing and a maintenance window, forever. Fall behind and you are doing multi-hop upgrades on an unsupported version, which is the worst combination available.</p>



<p class="wp-block-paragraph">Underneath that sit the components nobody counts when they say &#8220;we&#8217;ll just run Kubernetes&#8221;: a CNI plugin, a CSI driver, an ingress controller, cert-manager, external-dns, a metrics pipeline, an autoscaler, RBAC, and whatever operators your databases need. Each has its own release cycle, its own breaking changes and its own opinions about the others. That is where the Tuesday goes.</p>



<p class="wp-block-paragraph">Then there is debugging. A request that used to fail in one place now fails in one of several, and telling apart an application bug from a NetworkPolicy, a readiness probe, a DNS caching issue or a node under memory pressure is a genuine skill. On a good team that skill exists. On a three-person team it exists in one person&#8217;s head and goes on holiday with them.</p>



<h2 class="wp-block-heading">Signals you don&#8217;t need it yet</h2>



<ul class="wp-block-list">
<li>You have fewer than about five services, and most of them are one binary and a database.</li>
<li>One team owns everything, so there is nothing to isolate from anyone.</li>
<li>Traffic is predictable enough that you size for peak and stop thinking about it.</li>
<li>Nobody on the team has run a cluster in production before.</li>
<li>Your deploys are already fine. Nobody is complaining about them.</li>
<li>You cannot name the specific problem Kubernetes is solving, only the general one.</li>
<li>The cluster would run on two nodes, which means you have a scheduler for a decision with two possible answers.</li>
</ul>



<h2 class="wp-block-heading">Signals you probably do</h2>



<ul class="wp-block-list">
<li>Multiple teams deploying independently and tripping over each other&#8217;s environments.</li>
<li>Enough services that per-service deployment scripts have become their own maintenance problem.</li>
<li>Genuinely variable load where autoscaling saves real money, not hypothetical money.</li>
<li>A compliance or tenancy requirement that maps cleanly onto namespaces and network policy.</li>
<li>You are already running an operator-based product, like a database platform, that expects Kubernetes.</li>
<li>Somebody&#8217;s whole job is the platform, and it is not also somebody&#8217;s whole job to ship features.</li>
</ul>



<p class="wp-block-paragraph">That last one is the honest test. Kubernetes is not expensive to install. It is expensive to own, and ownership needs a name attached to it.</p>



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



<h2 class="wp-block-heading">What I&#8217;d reach for instead</h2>



<p class="wp-block-paragraph">Roughly in order of how much you take on.</p>



<p class="wp-block-paragraph"><strong>One server, systemd, and a reverse proxy.</strong> Underrated to the point of being contrarian, and completely adequate for a large number of real businesses. A VPS from Hetzner, InterServer or DigitalOcean, nginx or Caddy in front, systemd units for your services, automated backups off the box. Restart policies and health checks are built in; you are not going without them, you are just getting them from init instead of from a control loop.</p>



<p class="wp-block-paragraph"><strong>Docker Compose on one box.</strong> The step up when you want containers without a scheduler. The entire deployment is a file you can read in one screen:</p>



<pre class="wp-block-code"><code>services:
  app:
    image: ghcr.io/acme/api:1.4.2      # pinned, deployed by changing this line
    restart: always
    ports: ["127.0.0.1:8080:8080"]     # proxy handles TLS and the public port
    env_file: .env
    depends_on: [db]

  db:
    image: postgres:16                 # pin the major, upgrade deliberately
    restart: always
    volumes: ["pgdata:/var/lib/postgresql/data"]

volumes:
  pgdata:</code></pre>



<p class="wp-block-paragraph">The honest limitation: one box is one box. Losing it is an outage, and a redeploy is a brief gap unless you put something in front. For plenty of internal tools and early products, that is an acceptable trade stated out loud rather than an oversight.</p>



<p class="wp-block-paragraph"><strong>A platform that runs containers for you.</strong> Cloud Run, App Runner, Fly.io, Render, Railway. You hand over a container image and get scaling, TLS, rolling deploys and health checks without operating any of it. This is the option most teams should look at hardest, because it buys back the majority of what Kubernetes offers at almost none of the operational cost. You pay in per-unit pricing and in reduced control, and both are usually worth it below a certain scale.</p>



<p class="wp-block-paragraph"><strong>ECS on Fargate, or Nomad.</strong> Real schedulers with a fraction of the concepts. If you need orchestration but not the ecosystem, these are legitimate destinations rather than waypoints on the road to Kubernetes.</p>



<p class="wp-block-paragraph">And if you have decided Kubernetes genuinely is the answer, use a managed control plane, and consider a lightweight distribution such as k3s for small or edge deployments. Do not build the cluster yourself to save money. That is the most expensive saving in infrastructure.</p>



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



<p class="wp-block-paragraph"><em>&#8220;We&#8217;ll need it eventually.&#8221;</em> Possibly. Adopting it three years early costs three years of upgrade cycles to buy an option you may not exercise, and the migration is not much harder later than it is now.</p>



<p class="wp-block-paragraph"><em>&#8220;It&#8217;s the industry standard.&#8221;</em> For orchestrating fleets, yes. Most applications are not fleets. Standard does not mean universally appropriate, and the same argument would put a load balancer in front of a single server.</p>



<p class="wp-block-paragraph"><em>&#8220;It avoids vendor lock-in.&#8221;</em> Partly true and frequently oversold. You have swapped a cloud provider&#8217;s API for a distribution, a set of controllers, and a pile of YAML that encodes assumptions about all of them. Portability lives in your container images and your data, not in the scheduler.</p>



<p class="wp-block-paragraph"><em>&#8220;It&#8217;ll help us hire.&#8221;</em> It helps you hire people who want to run Kubernetes. Whether that is the same as the people who will build your product is worth a moment&#8217;s thought.</p>



<h2 class="wp-block-heading">You&#8217;re not painting yourself into a corner</h2>



<p class="wp-block-paragraph">This is the part that makes the decision easy to defer, and it is the most useful thing in this post.</p>



<p class="wp-block-paragraph">The durable investments are containerising your application, externalising configuration into environment variables, keeping state out of the application and in a database or object storage, exposing a health endpoint, and logging to stdout. Do those and your app already satisfies most of what a Kubernetes manifest asks for. The scheduler underneath is genuinely swappable.</p>



<p class="wp-block-paragraph">So the choice is not Kubernetes now or a painful rewrite later. It is Kubernetes now, or the same containers running somewhere simpler until the complexity is earned.</p>



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



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



<h3 class="wp-block-heading">How many services before Kubernetes makes sense?</h3>



<p class="wp-block-paragraph">There is no clean number, and anyone offering one is guessing. The better question is how many teams deploy independently. One team can coordinate without a platform. Four cannot, and that is when the abstraction starts paying for itself.</p>



<h3 class="wp-block-heading">Is Docker Compose really production-ready?</h3>



<p class="wp-block-paragraph">On a single host, for a workload that tolerates brief restarts, yes. It runs plenty of real systems. What it does not give you is failover across machines, so be clear that losing the host means an outage and decide whether that is acceptable rather than discovering it.</p>



<h3 class="wp-block-heading">Doesn&#8217;t managed Kubernetes remove the operational burden?</h3>



<p class="wp-block-paragraph">It removes the control plane, which is the part that was never your main problem. Ingress, storage, networking, upgrades and the add-on ecosystem all remain yours. Managed offerings genuinely help, and they narrow the gap rather than closing it.</p>



<h3 class="wp-block-heading">What about autoscaling? I can&#8217;t get that elsewhere.</h3>



<p class="wp-block-paragraph">You can. Cloud Run, App Runner, Fly and ECS all autoscale, several of them to zero, with no cluster to maintain. Autoscaling is a common reason to reach for Kubernetes and one of the weakest, because it is the feature most thoroughly commoditised elsewhere.</p>



<h3 class="wp-block-heading">How hard is it to migrate later?</h3>



<p class="wp-block-paragraph">Much easier than migrating off it. If your app is containerised, stateless, configured by environment and observable, moving to Kubernetes is mostly writing manifests. The hard parts of that migration are the ones you should be fixing anyway.</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">Kubernetes is not complicated by accident. It is complicated because it solves a genuinely complicated problem, and if you do not have that problem you are paying the complexity and collecting none of the benefit.</p>



<p class="wp-block-paragraph">So make it a decision with a reason attached. Write down the specific thing you cannot do today, and check whether a cluster is the cheapest way to fix it. Sometimes it is. Often the honest answer is one server, a reverse proxy, and an afternoon spent on backups instead.</p>



<h2 class="wp-block-heading">Trying to decide?</h2>



<p class="wp-block-paragraph">I get called in on both sides of this: teams drowning in a cluster they did not need, and teams who genuinely need one and are putting it off. Work I take on:</p>



<ul class="wp-block-list">
<li>A straight assessment of whether Kubernetes fits your team and workload, with the reasoning written down rather than an opinion delivered.</li>
<li>Building the simpler thing properly: single-host or Compose deployments with TLS, health checks, deploys and backups that hold up in production.</li>
<li>Moving off an over-provisioned cluster onto something proportionate, without downtime.</li>
<li>Getting an application genuinely portable so the decision stays reversible: containerised, stateless, configured by environment, observable.</li>
<li>Setting up managed Kubernetes properly when it is the right answer, including the upgrade process nobody plans for.</li>
<li>Reviewing the cost, in money and in engineering hours, of what you currently run.</li>
</ul>



<p class="wp-block-paragraph">Tell me how many services you run and how many people deploy them, and I will tell you what I would build.</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/when-kubernetes-is-overkill/">The Cluster Becomes the Product: When Kubernetes Is Overkill</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>One Label Too Many: Centralized Logging With Loki Without Wrecking It</title>
		<link>https://john-nessime.com/blog/devops/centralized-logging-loki/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Sat, 01 Aug 2026 09:24:17 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Grafana Alloy]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[LogQL]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Loki]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Observability]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=68</guid>

					<description><![CDATA[<p>Loki was fast until someone added one more label. Now queries time out during incidents and the index no longer fits in memory. Here's the model you have to internalise, what replaced Promtail, and how to keep labels, retention and storage costs under control.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking 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 alert fires at eleven at night. You open Grafana, pick the right time range, type a query, and watch the spinner. Forty seconds later it times out. You narrow the range to five minutes and it comes back, eventually. The one tool you need during an incident is the one that has become unusable.</p>



<p class="wp-block-paragraph">Nothing about the Loki config changed. What changed is that three weeks ago somebody added <code>request_id</code> as a label because it seemed helpful, and Loki has been quietly building millions of separate streams ever since. The index that used to fit comfortably in memory does not. Every query now has to open thousands of tiny chunks instead of a handful of large ones.</p>



<p class="wp-block-paragraph">This is the failure mode that defines <strong>centralized logging with Loki</strong>, and it is almost entirely self-inflicted. Loki is cheap and fast when you use it the way it was designed and miserable when you bring habits from Elasticsearch. Nothing warns you at the moment you make the mistake. The bill comes weeks later, during an incident.</p>



<p class="wp-block-paragraph">This covers the model you have to internalise, how to choose labels, what replaced Promtail (it is gone, and every tutorial older than this year is wrong about it), the LogQL patterns worth knowing, and how to keep retention and storage costs under control.</p>



<h2 class="wp-block-heading">Loki is not Elasticsearch, and that is the entire design</h2>



<p class="wp-block-paragraph">Elasticsearch builds a full inverted index over every token in every log line. Powerful, and the index often ends up as large as the logs themselves. That is where the cost comes from.</p>



<p class="wp-block-paragraph">Loki indexes only labels. The log content is compressed into chunks and pushed to object storage, untouched and unindexed. A query does two things: it uses the index to select which streams to read, then it brute-force scans the contents of those chunks in parallel.</p>



<p class="wp-block-paragraph">The consequence is a rule worth writing on a wall: <strong>labels select, filters search.</strong> Labels exist to narrow down which chunks get opened. Everything else is a filter applied at query time. Scanning is genuinely fast because it is embarrassingly parallel, so filtering on content is not the expensive operation people assume.</p>



<p class="wp-block-paragraph">What is expensive is streams. A stream is one unique combination of label values, and each gets its own chunks. Do the arithmetic before you add a label:</p>



<ul class="wp-block-list">
<li>8 jobs, 3 environments, 12 hosts gives you 288 streams. Comfortable.</li>
<li>Add <code>request_id</code>, and you multiply that by however many requests you serve. The index explodes, chunks get flushed while nearly empty, and object storage fills with millions of tiny files that queries must open individually.</li>
</ul>



<p class="wp-block-paragraph">Ingesters also hold every active stream in memory. Cardinality does not just make queries slow; it is how Loki gets OOM-killed.</p>



<h2 class="wp-block-heading">Choosing labels</h2>



<p class="wp-block-paragraph">A label earns its place if it satisfies all three: the set of values is bounded and small, you know the values in advance, and you would actually use it to narrow a search.</p>



<ul class="wp-block-list">
<li><strong>Good:</strong> <code>job</code>, <code>service</code>, <code>env</code>, <code>host</code>, <code>namespace</code>, <code>level</code>.</li>
<li><strong>Bad:</strong> <code>request_id</code>, <code>trace_id</code>, <code>user_id</code>, <code>session_id</code>, full URL paths, IP addresses, timestamps, durations, anything derived from the log content itself.</li>
</ul>



<p class="wp-block-paragraph">Two subtler traps. <code>instance</code> including an ephemeral port is unbounded. And in container environments, pod names churn constantly, so labelling by pod name gives you a new stream every deployment forever.</p>



<p class="wp-block-paragraph">Aim to keep the total under a few thousand active streams. Check rather than guess, using <code>logcli</code>, which reports cardinality per label:</p>



<pre class="wp-block-code"><code># Which of your labels is quietly out of control?
logcli series '{}' --analyze-labels</code></pre>



<p class="wp-block-paragraph">Run that before you have a problem. It is the single most useful diagnostic here and almost nobody knows about it.</p>



<h3 class="wp-block-heading">The escape hatch: structured metadata</h3>



<p class="wp-block-paragraph">&#8220;Never label a trace ID&#8221; is correct but unsatisfying, because sometimes you genuinely need to find every line for one trace. Loki 3 answers this with structured metadata: key-value pairs attached to a log line, stored alongside it, <em>not</em> in the index and not creating streams.</p>



<pre class="wp-block-code"><code>limits_config:
  # Off by default on older configs. Turn it on before your
  # agent starts sending structured metadata, or the writes
  # get rejected and you spend an hour puzzling over it.
  allow_structured_metadata: true</code></pre>



<p class="wp-block-paragraph">Query it with the same label-filter syntax, and it costs you nothing in cardinality:</p>



<pre class="wp-block-code"><code>{job="api", env="production"} | trace_id="9f2c1b4e"</code></pre>



<p class="wp-block-paragraph">So the decision becomes three-way rather than two-way: bounded and used for selection becomes a label, high-cardinality but frequently searched becomes structured metadata, and everything else stays in the line and gets parsed at query time.</p>



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



<h2 class="wp-block-heading">Promtail is gone. Use Alloy.</h2>



<p class="wp-block-paragraph">This one has a date and it matters, because it invalidates most of the Loki material on the internet.</p>



<p class="wp-block-paragraph">Promtail, the agent every Loki tutorial tells you to install, was deprecated in February 2025 and reached end of life on 2 March 2026. No security patches, no bug fixes, no updates. Grafana Alloy, their distribution of the OpenTelemetry Collector, is the replacement, and it handles logs, metrics, traces and profiles in one binary instead of running a separate agent per signal.</p>



<p class="wp-block-paragraph">If you already run Promtail, there is a conversion tool rather than a rewrite:</p>



<pre class="wp-block-code"><code># Converts an existing Promtail config to Alloy syntax.
# Read the output before deploying it; the converter flags
# anything it could not translate cleanly.
alloy convert --source-format=promtail --output=config.alloy promtail.yaml</code></pre>



<p class="wp-block-paragraph">Alloy configuration is a component graph rather than a YAML pipeline. Components reference each other&#8217;s outputs, which reads oddly at first and then makes more sense than nested YAML stages:</p>



<pre class="wp-block-code"><code>// Find the files, and attach the labels that will select them later.
// Note what is NOT here: nothing derived from log content.
local.file_match "system" {
  path_targets = [{
    __path__ = "/var/log/syslog",
    job      = "syslog",
    host     = constants.hostname,
    env      = "production",
  }]
}

loki.source.file "system" {
  targets    = local.file_match.system.targets
  forward_to = [loki.write.default.receiver]
}

loki.write "default" {
  endpoint {
    url = "http://loki:3100/loki/api/v1/push"
  }
}</code></pre>



<p class="wp-block-paragraph">Between source and write you can insert a <code>loki.process</code> component to parse lines, drop noise, and promote fields into structured metadata. Do the dropping here rather than at the Loki end: logs you never ship cost nothing to store, index or query, and debug-level chatter from a busy service is usually the single largest slice of ingest.</p>



<p class="wp-block-paragraph">For Docker hosts, Alloy has components that discover containers and read their logs directly, which is tidier than tailing the JSON files under <code>/var/lib/docker</code> and surviving a logging-driver change.</p>



<h2 class="wp-block-heading">Deployment shape and storage</h2>



<p class="wp-block-paragraph">Loki ships as one binary that runs in different modes depending on the <code>-target</code> flag.</p>



<ul class="wp-block-list">
<li><strong>Monolithic.</strong> Everything in one process. Right for a single VPS, a homelab, or anything up to a modest daily volume. Do not feel bad about this; a lot of people run microservices mode for a workload one process would handle.</li>
<li><strong>Simple scalable.</strong> Splits into read, write and backend targets behind a load balancer. The sensible next step, and where most self-hosted setups should stop.</li>
<li><strong>Microservices.</strong> Every component separately. Genuinely necessary at large scale and a substantial operational burden below it.</li>
</ul>



<p class="wp-block-paragraph">For storage, use TSDB with schema v13. Older guides show <code>boltdb-shipper</code>; that is the previous generation. Chunks belong in object storage in anything you care about, because filesystem storage means one disk, one node, and no redundancy.</p>



<pre class="wp-block-code"><code>schema_config:
  configs:
    # Schema changes are additive. Add a new entry with a future
    # 'from' date rather than editing the existing one, or Loki
    # cannot read the data it already wrote.
    - from: 2024-01-01
      store: tsdb
      object_store: s3
      schema: v13
      index:
        prefix: index_
        period: 24h</code></pre>



<p class="wp-block-paragraph">That &#8220;additive&#8221; note is worth taking seriously. Editing an existing schema entry in place is one of the few ways to make historical logs unreadable.</p>



<p class="wp-block-paragraph">Any S3-compatible store works, which is where the cost advantage lives. Chunks are compressed and write-once, so cheaper tiers suit them well: S3 itself, or MinIO if you want it self-hosted, or one of the cheaper compatible providers like Backblaze B2 or Cloudflare R2. Watch egress pricing rather than storage pricing, since queries read chunks back.</p>



<h2 class="wp-block-heading">LogQL worth actually knowing</h2>



<p class="wp-block-paragraph">Every query starts with a stream selector in braces. That part is mandatory and it is what decides how much data gets read.</p>



<pre class="wp-block-code"><code># Order matters for performance:
#   1. select streams (index lookup)
#   2. filter lines (cheap string match, runs on every line)
#   3. parse (expensive, so run it on the smallest set possible)
{job="api", env="production"} |= "timeout" | json | status="500"</code></pre>



<p class="wp-block-paragraph">People routinely write <code>| json</code> before the line filter and then wonder why the query is slow. Parsing every line in the range to discard most of them is exactly the wrong order.</p>



<p class="wp-block-paragraph">Loki also turns logs into metrics, which is where it earns its place next to Prometheus:</p>



<pre class="wp-block-code"><code># Error rate per service, graphable and alertable.
sum by (job) (rate({env="production"} |= "level=error" [5m]))

# Which streams are eating your ingest budget?
topk(10, sum by (job, host) (count_over_time({env="production"}[1h])))</code></pre>



<p class="wp-block-paragraph">That second one is worth saving as a dashboard panel. Ingest problems announce themselves there long before they show up as a slow query.</p>



<p class="wp-block-paragraph">Loki&#8217;s ruler evaluates the same expressions as alerting rules and sends them to Alertmanager, so &#8220;alert when this service logs more than N errors in five minutes&#8221; needs no extra component if you already run Prometheus.</p>



<h2 class="wp-block-heading">Retention, and why it silently does nothing</h2>



<p class="wp-block-paragraph">Setting <code>retention_period</code> is not enough. Retention is enforced by the compactor, and the compactor does not do it unless you tell it to. Plenty of people set a retention period, watch their bucket keep growing, and never connect the two.</p>



<pre class="wp-block-code"><code>compactor:
  working_directory: /loki/compactor
  # Without this, the compactor compacts the index and
  # deletes nothing. This is the line people miss.
  retention_enabled: true
  delete_request_store: s3

limits_config:
  retention_period: 720h        # 30 days, global default</code></pre>



<p class="wp-block-paragraph">Per-stream retention lets you keep what matters and discard what does not: audit logs for a year, application debug logs for three days. That difference tends to matter more to the bill than any storage tier choice.</p>



<p class="wp-block-paragraph">Deletion is asynchronous. Chunks disappear from object storage some time after the index entries go, so do not panic if the bucket does not shrink immediately.</p>



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



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



<h3 class="wp-block-heading">Queries are slow or time out</h3>



<p class="wp-block-paragraph">Cardinality first. Run <code>logcli series '{}' --analyze-labels</code> and look for a label with a suspiciously large value count. Then check query shape: a selector that matches everything, a parser before a line filter, or a time range far wider than needed. Adding query frontend caching helps repeated queries but will not rescue a bad label set.</p>



<h3 class="wp-block-heading">Stream limit exceeded</h3>



<p class="wp-block-paragraph">Loki is telling you the truth. Raising <code>max_global_streams_per_user</code> buys time and makes the underlying problem worse. Find the label that is generating streams and move it to structured metadata or into the line.</p>



<h3 class="wp-block-heading">Rate limit or ingestion errors</h3>



<p class="wp-block-paragraph">Two different limits produce similar-looking rejections: a global ingestion rate, and a per-stream rate. Per-stream limits usually mean one very chatty stream rather than too much traffic overall, and the fix is to split it by a label or drop the noise at the agent.</p>



<h3 class="wp-block-heading">Logs are not appearing at all</h3>



<p class="wp-block-paragraph">Work forward from the agent. Check the agent&#8217;s own logs first, then whether it can reach the push endpoint, then whether the file is actually readable by the agent&#8217;s user. Container log permissions and SELinux catch people constantly. In Grafana, remember the label browser only shows labels seen recently, so an empty dropdown often means nothing has arrived, not that the query is wrong.</p>



<h3 class="wp-block-heading">Logs stop after an agent restart</h3>



<p class="wp-block-paragraph">The position file that tracks how far it read is not persisting. In a container, that means it is not on a mounted volume, so every restart re-reads or skips depending on configuration.</p>



<h3 class="wp-block-heading">Storage keeps growing despite retention</h3>



<p class="wp-block-paragraph">Confirm <code>retention_enabled: true</code> is actually set, that exactly one compactor is running, and that it has write access to the object store. A compactor that cannot delete usually says so in its logs and nowhere else.</p>



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



<ul class="wp-block-list">
<li>Treating labels like Elasticsearch fields and labelling everything.</li>
<li>Promoting a parsed value into a label without checking how many distinct values it has.</li>
<li>Following a tutorial that installs Promtail, which is now end of life.</li>
<li>Writing <code>| json</code> before the line filter.</li>
<li>Setting <code>retention_period</code> without <code>retention_enabled: true</code>.</li>
<li>Running more than one compactor.</li>
<li>Editing an existing <code>schema_config</code> entry instead of adding a new one.</li>
<li>Filesystem storage on a single node for anything you would miss.</li>
<li>Shipping every debug line and then paying to store, index and scan it.</li>
<li>Raising stream limits instead of fixing cardinality.</li>
<li>Running microservices mode for a workload the single binary handles comfortably.</li>
<li>No dashboard showing ingest volume per stream, so growth is invisible until queries break.</li>
</ul>



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



<ul class="wp-block-list">
<li>Decide labels deliberately: bounded, known in advance, used for selection.</li>
<li>Put high-cardinality but searchable values in structured metadata, not labels.</li>
<li>Run <code>--analyze-labels</code> periodically, not just when something breaks.</li>
<li>Drop noise at the agent, before it costs you anything downstream.</li>
<li>Emit structured logs from your applications so parsers are cheap and reliable.</li>
<li>Start monolithic and move to simple scalable only when you have a reason.</li>
<li>TSDB, schema v13, chunks in object storage.</li>
<li>Turn retention on properly and use per-stream retention to keep costs sane.</li>
<li>Order queries: select, filter, parse.</li>
<li>Alert on log-derived metrics through the ruler rather than eyeballing dashboards.</li>
<li>Graph ingest volume per job so cardinality growth shows up as a trend.</li>
</ul>



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



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



<h3 class="wp-block-heading">Is Loki a drop-in replacement for the ELK stack?</h3>



<p class="wp-block-paragraph">No, and expecting it to be is where most disappointment comes from. Loki is dramatically cheaper to run and much better suited to &#8220;show me the logs for this service around this time&#8221;. Elasticsearch is better at ad-hoc full-text search across everything with no idea where to start. Pick based on which of those you do more often.</p>



<h3 class="wp-block-heading">Why is my Loki so slow?</h3>



<p class="wp-block-paragraph">Almost always label cardinality. Run <code>logcli series '{}' --analyze-labels</code> and look for the outlier. The second most common cause is a query that parses before it filters. Hardware is a distant third.</p>



<h3 class="wp-block-heading">Should I still use Promtail?</h3>



<p class="wp-block-paragraph">No. It reached end of life in March 2026 and receives no security patches. Use Grafana Alloy, and run <code>alloy convert</code> against your existing config rather than rewriting it from scratch.</p>



<h3 class="wp-block-heading">How many labels is too many?</h3>



<p class="wp-block-paragraph">The count matters less than the product of their distinct values. Five labels with a handful of values each is fine. Three labels where one has fifty thousand values is not. Multiply before you add.</p>



<h3 class="wp-block-heading">Can I run Loki on a single VPS?</h3>



<p class="wp-block-paragraph">Yes, and for a handful of services it is a perfectly good answer. Monolithic mode alongside Grafana and Prometheus fits on a modest box from any provider. Point chunk storage at an object store rather than local disk so a dead server does not take your history with it.</p>



<h3 class="wp-block-heading">How do I search for a specific request across services?</h3>



<p class="wp-block-paragraph">Attach the trace or request ID as structured metadata at the agent, then filter on it. You get the high-cardinality lookup you wanted without creating a stream per request.</p>



<h3 class="wp-block-heading">Self-host or use a managed service?</h3>



<p class="wp-block-paragraph">Self-hosting a monolithic Loki is genuinely low effort. Self-hosting a highly available multi-tenant Loki is a real ongoing job, and at that point Grafana Cloud or a hosted alternative deserves an honest cost comparison including your time. The break-even is further out than vendors suggest and closer than enthusiasts admit.</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">Labels select, filters search. Every serious problem with centralized logging with Loki traces back to someone treating a label as a search field. Loki will accept it without complaint, and the cost arrives weeks later as a query that will not finish.</p>



<p class="wp-block-paragraph">So before adding any label, multiply out how many streams it creates. If the answer is more than a few thousand, it belongs in structured metadata or in the log line. And check your cardinality on a schedule, because this degrades gradually and you will not notice the day it crosses the line.</p>



<h2 class="wp-block-heading">Need a logging setup that stays fast?</h2>



<p class="wp-block-paragraph">Most Loki installations I get asked to look at were fine on day one and got slower every week. Work I take on:</p>



<ul class="wp-block-list">
<li>Diagnosing a slow Loki: cardinality analysis, query shape review, and a concrete list of labels to move.</li>
<li>Migrating Promtail configurations to Grafana Alloy, including Docker and Kubernetes log collection.</li>
<li>Building a centralized logging stack from scratch: Loki, Alloy, Grafana, object storage, retention and dashboards.</li>
<li>Restructuring labels and introducing structured metadata without losing queryability.</li>
<li>Retention and storage cost work: per-stream policies, agent-side dropping, object storage tiering.</li>
<li>Log-based alerting through the ruler, wired into an existing Prometheus and Alertmanager setup.</li>
</ul>



<p class="wp-block-paragraph">Send me the output of <code>logcli series '{}' --analyze-labels</code> and a query that feels slow, and I will tell you where the problem is.</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/centralized-logging-loki/">One Label Too Many: Centralized Logging With Loki Without Wrecking It</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
		<item>
		<title>Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</title>
		<link>https://john-nessime.com/blog/devops/fail-fast-pipeline/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Fri, 31 Jul 2026 23:12:22 +0000</pubDate>
				<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Technical Guides]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Automation]]></category>
		<category><![CDATA[Bash]]></category>
		<category><![CDATA[CI/CD]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Exit Codes]]></category>
		<category><![CDATA[GitHub Actions]]></category>
		<category><![CDATA[GitLab CI]]></category>
		<category><![CDATA[Logging]]></category>
		<category><![CDATA[Pipefail]]></category>
		<category><![CDATA[Pipeline Design]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Shell Scripting]]></category>
		<category><![CDATA[SRE]]></category>
		<category><![CDATA[Terraform]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=55</guid>

					<description><![CDATA[<p>A green pipeline that ships a broken build is worse than a red one. Here is how to order stages so failures surface in seconds, stop shell pipes from swallowing exit codes, and write failure output that tells the next engineer exactly what to fix.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</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 deploy went out at the end of the day. Pipeline green, every stage a tick, nobody thought about it again. Twenty minutes later support asks why checkout is returning 502.</p>



<p class="wp-block-paragraph">You go back through the logs and there it is, four hundred lines up, in a step that reported success: a download that returned an HTML error page, got piped into <code>tar</code>, and produced an archive containing nothing useful. The shell reported the exit status of <code>tar</code>, not of <code>curl</code>. The build carried on. The pipeline was never lying on purpose. It just never asked the right question.</p>



<p class="wp-block-paragraph">Two things go wrong with pipelines, and only one of them gets talked about. The famous one is slow feedback: thirty-four minutes of build and integration tests before a linter tells you a file is missing a trailing newline. The expensive one is a pipeline that passes when it should have stopped. A <strong>fail fast pipeline</strong> fixes the first problem. Making it explain itself fixes the second, and that is the half most teams skip.</p>



<p class="wp-block-paragraph">This post covers both: how to order stages so real failures surface in seconds, how to stop the shell from hiding non-zero exits, how to design failure output that hands the next engineer the fix, and the troubleshooting steps for when a pipeline stubbornly refuses to fail at the right moment.</p>



<h2 class="wp-block-heading">The failure that actually bites: a pipeline that lies</h2>



<p class="wp-block-paragraph">Start here, because everything else is optimisation on top. A slow pipeline wastes your afternoon. A silently passing pipeline ships a broken artifact and burns a day of debugging in production, usually while somebody is on a call with a customer.</p>



<p class="wp-block-paragraph">The shell is where most of this happens. Bash gives you three defaults that are wrong for CI, and one line fixes all three.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

# Without pipefail this line exits 0 even when curl fails,
# because the shell reports the status of the LAST command
# in the pipe, which is tar.
curl -sSL https://example.com/artifact.tar.gz | tar -xz</code></pre>



<h3 class="wp-block-heading">What each flag actually protects</h3>



<ul class="wp-block-list">
<li><strong><code>-e</code></strong> stops the script when a command exits non-zero. Without it, a failed database migration is followed by a successful <code>echo</code>, and the script exits 0 because the last command succeeded.</li>
<li><strong><code>-u</code></strong> turns an unset variable into an error. This is the one that saves you from <code>rm -rf "$PREFIX/build"</code> expanding to <code>rm -rf "/build"</code> when someone renames an environment variable in the pipeline settings and forgets the script.</li>
<li><strong><code>-o pipefail</code></strong> makes a pipeline return the first non-zero status instead of the last one. Every <code>curl | tar</code>, <code>cat | jq</code>, and <code>docker logs | grep</code> in your scripts is currently reporting only the exit code of the thing on the right.</li>
</ul>



<p class="wp-block-paragraph">The exit status is only half the story though. Plenty of tools return 0 while doing something useless, and <code>curl</code> is the classic offender.</p>



<pre class="wp-block-code"><code># curl exits 0 here even when the server returns 500,
# and you end up untarring an HTML error page.
curl -sSL "$ARTIFACT_URL" | tar -xz

# --fail          makes curl exit non-zero on 4xx and 5xx
# --show-error    keeps the reason visible even with --silent
# --location      follows redirects instead of saving a 302 body
curl --fail --silent --show-error --location "$ARTIFACT_URL" | tar -xz</code></pre>



<p class="wp-block-paragraph">Now the download failing stops the build, and <code>pipefail</code> makes sure that failure propagates past the pipe. The first fixes the check, the second makes sure the check is heard.</p>



<h3 class="wp-block-heading">Where <code>set -e</code> quietly gives up</h3>



<p class="wp-block-paragraph">This trips people up constantly. <code>set -e</code> is suspended whenever a command&#8217;s exit status is being consumed by something else. It is not a safety net, it is a default.</p>



<pre class="wp-block-code"><code>set -e

# -e does NOT stop the script here.
# The if statement consumes the exit status.
if grep -q "TODO" src/main.go; then
  echo "found"
fi

# -e does NOT stop the script here either.
# The left side of || is explicitly allowed to fail.
run_migrations || echo "migrations failed, continuing anyway"

# -e DOES stop the script here.
run_migrations</code></pre>



<p class="wp-block-paragraph">The same trap applies inside containers. On Debian-based images <code>/bin/sh</code> is <code>dash</code>, which has no <code>pipefail</code> option at all, so putting <code>set -o pipefail</code> inside a <code>RUN</code> line fails outright. Set the shell at the top of the Dockerfile instead. <a href="https://github.com/hadolint/hadolint" target="_blank" rel="noreferrer noopener">Hadolint</a> flags this as DL4006 and it is worth wiring into your static stage.</p>



<pre class="wp-block-code"><code># Set this before any RUN instruction that contains a pipe.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]

RUN curl --fail --silent --show-error --location 
      https://example.com/tool.tar.gz | tar -xz -C /usr/local/bin</code></pre>



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



<h2 class="wp-block-heading">Order stages by cost, not by category</h2>



<p class="wp-block-paragraph">Most pipelines are laid out the way the team thinks about work: tests, then build, then security, then deploy. That grouping is comfortable and it is the wrong axis. Order by two things instead, cost and failure probability.</p>



<p class="wp-block-paragraph">The rule I use: <strong>every stage should be cheaper than the one after it and more likely to fail than the one after it.</strong> If a check catches ten percent of your broken commits and takes fifteen seconds, it goes in front of the check that catches two percent and takes nine minutes. That is the whole design principle behind a fail fast pipeline.</p>



<ol class="wp-block-list">
<li><strong>Syntax and static analysis.</strong> <code>shellcheck</code>, <code>hadolint</code>, <code>yamllint</code>, <code>terraform validate</code>, <code>tflint</code>, formatter checks, type checks. Seconds, no dependencies, no network. This stage catches an embarrassing share of broken pushes.</li>
<li><strong>Unit tests.</strong> No database, no containers, no network. If they need any of those, they are integration tests and belong further down.</li>
<li><strong>Secrets and dependency scanning.</strong> Fast, and a leaked credential is worth stopping before you build an image around it.</li>
<li><strong>Build.</strong> This is where you start spending real minutes and cache space.</li>
<li><strong>Integration and end-to-end.</strong> Slow, flaky, expensive to run. Everything above should have already caught the boring failures.</li>
<li><strong>Deploy and post-deploy verification.</strong></li>
</ol>



<p class="wp-block-paragraph">A note on the trade-off, because this ordering costs something. Splitting the fast checks into their own stage adds a checkout and a container pull before your unit tests even start. On a busy shared runner that overhead is real. If your static stage takes twelve seconds of work and forty seconds of setup, merge it into the test job and run it as the first step instead. The principle is failure order, not job count.</p>



<p class="wp-block-paragraph">The other thing that keeps the fast lane fast is caching, and it is the piece people get wrong in the opposite direction: they cache so aggressively that a stale dependency tree hides a genuine break. Cache the package manager&#8217;s download directory, not the resolved dependency tree, and key the cache on the lockfile hash.</p>



<h2 class="wp-block-heading">Let the platform kill work you already know is doomed</h2>



<p class="wp-block-paragraph">Ordering stages is only useful if the platform actually stops when something breaks. Three settings do most of the work, and they exist under different names on every runner.</p>



<h3 class="wp-block-heading">GitHub Actions</h3>



<pre class="wp-block-code"><code>concurrency:
  # A new push to the same branch cancels the run that is
  # already in flight. Nobody needs results for a commit
  # that has been superseded.
  group: ci-${{ github.ref }}
  cancel-in-progress: true

jobs:
  static:
    runs-on: ubuntu-latest
    timeout-minutes: 5          # a hung job is worse than a failed one
    steps:
      - uses: actions/checkout@v4   # pin to a full commit SHA in production
      - run: shellcheck scripts/*.sh
      - run: hadolint Dockerfile

  test:
    needs: static               # will not start until static passes
    runs-on: ubuntu-latest
    timeout-minutes: 15
    strategy:
      fail-fast: false          # see note below
      matrix:
        suite: [unit, contract]
    steps:
      - uses: actions/checkout@v4
      - run: make test-${{ matrix.suite }}</code></pre>



<p class="wp-block-paragraph">That <code>fail-fast: false</code> looks like it contradicts the whole post, so it is worth explaining. Matrix fail-fast defaults to true: one leg fails, the rest get cancelled. That is right when the legs are redundant and you only need to know that something broke. It is wrong when the legs tell you different things. If your matrix is three runtime versions and only one fails, cancelling the others hides whether this is a version-specific problem or a general one, and you will burn a rerun finding out. Fail fast on the pipeline, not on the diagnosis.</p>



<p class="wp-block-paragraph"><code>timeout-minutes</code> is the setting people skip and regret. A job waiting on an interactive prompt, a dead package mirror, or a container that never becomes healthy will sit there consuming a runner until someone notices. Set a timeout on every job, generous enough that a slow day does not trip it and tight enough that a hang gets caught inside one coffee break.</p>



<h3 class="wp-block-heading">GitLab CI</h3>



<pre class="wp-block-code"><code>stages: [static, test, build, deploy]

default:
  interruptible: true     # allows auto-cancel when a newer pipeline starts
  retry:
    max: 2                # capped at 2 by GitLab, so 3 attempts total
    when:
      # Retry only on infrastructure problems.
      # Never retry script_failure: that is your code failing,
      # and retrying it just hides a flaky test.
      - runner_system_failure
      - stuck_or_timeout_failure
      - api_failure

lint:
  stage: static
  timeout: 5m
  script:
    - shellcheck scripts/*.sh

integration:
  stage: test
  needs: [lint]           # DAG: starts as soon as lint passes
  script:
    - make integration</code></pre>



<p class="wp-block-paragraph">The <code>retry: when</code> list is the important bit and it is the one people get lazy about. A bare <code>retry: 2</code> retries on everything including a genuine test failure, which converts an intermittent bug into an invisible one. Scope retries to the failure classes that are genuinely the infrastructure&#8217;s fault. Jenkins has <code>failFast true</code> for parallel stages, Buildkite and CircleCI have their own equivalents, and the reasoning transfers cleanly.</p>



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



<h2 class="wp-block-heading">A failure should hand you the answer, not the search</h2>



<p class="wp-block-paragraph">Here is the test I apply to a pipeline failure. Can someone who did not write the change, at an inconvenient hour, fix it from the failure output alone without opening the source? If not, the failure message is incomplete.</p>



<p class="wp-block-paragraph">Four things need to be in there:</p>



<ul class="wp-block-list">
<li><strong>What failed</strong>, named precisely. Not &#8220;build failed&#8221; but the command and its exit code.</li>
<li><strong>Why</strong>, in terms of the input rather than the internals. &#8220;lockfile is out of date with package.json&#8221;, not &#8220;npm ci exited 1&#8221;.</li>
<li><strong>Where</strong>, meaning the file, the line, or the config key that caused it.</li>
<li><strong>What to do next.</strong> One concrete action, even if it is just a runbook link.</li>
</ul>



<p class="wp-block-paragraph">In shell, a small helper plus an <code>ERR</code> trap gets you most of the way there.</p>



<pre class="wp-block-code"><code>#!/usr/bin/env bash
set -euo pipefail

fail() {
  echo "ERROR: $1" &gt;&amp;2
  echo "  fix: $2" &gt;&amp;2
  exit 1
}

# Catches anything you did not write an explicit check for,
# and tells you the line and the command that blew up.
trap 'echo "FAILED at line $LINENO: $BASH_COMMAND" &gt;&amp;2' ERR

[[ -f config/app.yaml ]] || fail 
  "config/app.yaml is missing" 
  "copy config/app.yaml.example and set DATABASE_URL"

[[ -n "${IMAGE_TAG:-}" ]] || fail 
  "IMAGE_TAG is empty" 
  "check the tag job output; it sets IMAGE_TAG from the git SHA"</code></pre>



<p class="wp-block-paragraph">Note <code>${IMAGE_TAG:-}</code> rather than <code>$IMAGE_TAG</code>. Under <code>set -u</code> the bare form aborts with a shell error before your nice message ever prints, which is technically correct and practically useless.</p>



<p class="wp-block-paragraph">On GitHub Actions you can go one step further and attach the error to the file and line, so it shows up as an annotation on the pull request instead of something to be dug out of a log.</p>



<pre class="wp-block-code"><code># Renders as a red annotation on that exact line in the diff view.
echo "::error file=deploy/values.yaml,line=42::image tag is empty"

# Visible, but does not fail the job.
echo "::warning file=Dockerfile,line=7::apt cache not cleaned"</code></pre>



<p class="wp-block-paragraph">Two more habits worth building. Upload logs, test reports, and screenshots as artifacts on failure, conditionally, so the evidence survives the job that produced it. And emit test results as JUnit XML so the platform renders a list of failed test names instead of asking people to scroll. Both are cheap and both cut the time between &#8220;it broke&#8221; and &#8220;I know why&#8221; more than any clever retry logic will.</p>



<h2 class="wp-block-heading">Exit codes are a contract, so use more than one</h2>



<p class="wp-block-paragraph">Most scripts have two states: 0 and &#8220;something went wrong&#8221;. That collapses distinctions the pipeline needs. Terraform gets this right and it is a good model to copy.</p>



<pre class="wp-block-code"><code># -detailed-exitcode changes the meaning of the exit code:
#   0 = succeeded, no changes
#   1 = error
#   2 = succeeded, changes present
set +e
terraform plan -detailed-exitcode -out=tfplan
code=$?
set -e

case "$code" in
  0) echo "No drift. Skipping apply." ;;
  2) echo "Changes detected. Holding for approval." ;;
  *) echo "Plan failed. See log above." &gt;&amp;2; exit "$code" ;;
esac</code></pre>



<p class="wp-block-paragraph">The <code>set +e</code> around it is deliberate: with <code>-e</code> active, exit code 2 would kill the script before you got to interpret it. This is one of the few places where turning the flag off is the correct move, and it is worth a comment in the file explaining why so nobody &#8220;fixes&#8221; it later.</p>



<p class="wp-block-paragraph">Do the same in your own scripts. Reserve a code for &#8220;the thing you asked for is not possible&#8221; versus &#8220;I could not reach the API&#8221; versus &#8220;your input is wrong&#8221;. The pipeline can then retry one and refuse to retry the others, which is the difference between a retry that absorbs a runner hiccup and a retry that hides a race condition for six months.</p>



<h2 class="wp-block-heading">Gate on what you care about, not what is easy to check</h2>



<p class="wp-block-paragraph">This is the deploy-stage version of the same lie. Plenty of commands confirm that a request was accepted, not that the thing you wanted happened.</p>



<pre class="wp-block-code"><code># Returns 0 the moment the API server accepts the manifest,
# even if every pod goes into CrashLoopBackOff a second later.
kubectl apply -f k8s/

# This is the step that actually waits for the rollout,
# and fails the job if it does not converge.
kubectl rollout status deployment/api --timeout=180s

# And this proves the application answers a request,
# not just that a pod reports Ready.
curl --fail --silent --show-error https://api.example.com/healthz</code></pre>



<p class="wp-block-paragraph">Run that last check against the public hostname, through your CDN, rather than against the pod directly. If you front the app with Cloudflare or similar, a smoke test that skips the edge will happily pass while real users hit a cached error page or a misrouted origin. Same reasoning applies to the origin host itself: if the app runs on a VPS from a provider like InterServer behind a reverse proxy, verify through the proxy, because that is the path traffic takes.</p>



<p class="wp-block-paragraph">Then track how long each stage takes over time. Pipeline duration and failure rate per stage are perfectly ordinary metrics to ship into Prometheus and graph in Grafana, or into whatever platform you already use for application monitoring. When the fast lane creeps from forty seconds to four minutes, that is a slow regression nobody will notice from a single run.</p>



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



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



<h3 class="wp-block-heading">The job passed but the artifact is wrong</h3>



<p class="wp-block-paragraph">Look for pipes first. Grep the scripts for <code>|</code> and check whether <code>pipefail</code> is set in that shell. Then check for tools that return 0 on failure: <code>curl</code> without <code>--fail</code>, <code>docker run</code> where the entrypoint swallows errors, anything wrapped in <code>|| true</code> that somebody added during an incident and never removed.</p>



<h3 class="wp-block-heading"><code>set -e</code> is set but the script keeps going</h3>



<p class="wp-block-paragraph">The failing command is almost certainly inside a condition, on the left of <code>||</code> or <code>&amp;&amp;</code>, or in a function that is itself being called from an <code>if</code>. Add <code>set -x</code> temporarily and read the trace to see exactly where control flow continued. Then use an explicit check with your <code>fail</code> helper rather than relying on <code>-e</code> at that point.</p>



<h3 class="wp-block-heading">Works locally, fails in CI (or the reverse)</h3>



<p class="wp-block-paragraph">Usually a shell difference. Your terminal runs bash or zsh, the runner may execute the script with <code>sh</code>. Add an explicit shebang, and on GitHub Actions set the shell on the step or at the job level rather than assuming. Inside containers, remember <code>dash</code> has no <code>pipefail</code> and no <code>[[ ]]</code>.</p>



<h3 class="wp-block-heading">The job hangs until the platform kills it</h3>



<p class="wp-block-paragraph">Something is waiting on stdin. Package managers prompting for confirmation, <code>ssh</code> asking about a host key, a database client waiting for a password. Add the non-interactive flags, set <code>CI=true</code> where tools respect it, and put a timeout on the job so the next occurrence fails in ten minutes instead of sixty.</p>



<h3 class="wp-block-heading">Fails on the first run, passes on rerun</h3>



<p class="wp-block-paragraph">Resist the retry. Log the failure with enough context to identify it later, quarantine the test into a non-blocking job if it is genuinely blocking the team, and give it an owner and a date. A blanket retry turns a flaky test into a permanent tax on everyone&#8217;s confidence in the pipeline.</p>



<h3 class="wp-block-heading">The failure output is a wall of noise</h3>



<p class="wp-block-paragraph">Suppress progress bars and spinners in CI, they render as thousands of unreadable lines. Then print a short summary block at the very end of the job, after the noise, listing what failed and what to do. People read the bottom of a log first.</p>



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



<ul class="wp-block-list">
<li>Writing <code>set -e</code> without <code>-o pipefail</code> and assuming the script is covered.</li>
<li>Using <code>|| true</code> to silence a step that fails intermittently, instead of finding out why.</li>
<li>Running the slowest, flakiest checks before the ones that take fifteen seconds.</li>
<li>Retrying on every failure class rather than only infrastructure faults.</li>
<li>No job timeouts, so a hang consumes a runner until someone notices.</li>
<li>Treating <code>kubectl apply</code> or a deploy API call as proof the deploy worked.</li>
<li>Failure messages that name the internal function that threw, and nothing the reader can act on.</li>
<li>Caching so aggressively that a stale dependency tree hides a real break.</li>
<li>Discarding logs and test reports when the job fails, which is exactly when you need them.</li>
<li>Turning off matrix fail-fast everywhere by reflex, so every broken commit costs the full matrix.</li>
</ul>



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



<ul class="wp-block-list">
<li>Put <code>set -euo pipefail</code> at the top of every shell script the pipeline runs, and set the Dockerfile <code>SHELL</code> before any piped <code>RUN</code>.</li>
<li>Order stages so each one is cheaper and more likely to fail than the next.</li>
<li>Give every job a timeout, and cancel superseded runs on the same branch.</li>
<li>Use explicit checks with actionable messages instead of relying on <code>-e</code> to catch everything.</li>
<li>Distinguish exit codes so the pipeline can react differently to different failures.</li>
<li>Verify deploys with a rollout status check plus a real HTTP request through the public path.</li>
<li>Upload artifacts and structured test reports on failure.</li>
<li>Pin action and image references to immutable digests so a green pipeline stays reproducible.</li>
<li>Track stage duration and failure rate over time, the same way you track application metrics.</li>
<li>Fix the failure message the first time somebody has to ask what it means.</li>
</ul>



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



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



<h3 class="wp-block-heading">What does &#8220;fail fast&#8221; actually mean in a CI/CD pipeline?</h3>



<p class="wp-block-paragraph">Two things. First, the pipeline stops as soon as it knows the result will be a failure, rather than finishing work whose outcome no longer matters. Second, the cheapest checks that are most likely to catch a problem run first, so the average failure surfaces in seconds. The second half is a design choice about ordering, not a platform setting.</p>



<h3 class="wp-block-heading">Is <code>set -e</code> enough on its own?</h3>



<p class="wp-block-paragraph">No. It is suspended inside conditions, on the left side of <code>&amp;&amp;</code> and <code>||</code>, and after <code>!</code>. It also does nothing about pipes, which is why <code>-o pipefail</code> matters, or about commands that return 0 while failing, which is why explicit checks matter. Use it as a baseline, not as your only line of defence.</p>



<h3 class="wp-block-heading">Should I disable matrix fail-fast?</h3>



<p class="wp-block-paragraph">Only when the legs give you different diagnostic information. If the matrix is runtime versions or operating systems and you need to know whether the break is universal or specific, let all legs run. If the legs are interchangeable shards of the same test suite, leave fail-fast on and save the compute.</p>



<h3 class="wp-block-heading">How do I stop a pipeline from passing when a step really failed?</h3>



<p class="wp-block-paragraph">Work through three things in order. Set <code>pipefail</code> so pipes propagate failure. Add the flags that make tools return non-zero on failure, such as <code>curl --fail</code>. Then replace implicit trust with explicit assertions: after a deploy, check that the rollout converged and that the service answers a request. Most silent passes come from one of those three gaps.</p>



<h3 class="wp-block-heading">Are automatic retries a good idea?</h3>



<p class="wp-block-paragraph">Only for failures that are the infrastructure&#8217;s fault: runner crashes, image pull errors, API timeouts from the CI platform itself. Never retry a script failure by default. And make retries visible, because a job that quietly succeeds on the third attempt is telling you something important that nobody sees.</p>



<h3 class="wp-block-heading">How long should a fast feedback stage take?</h3>



<p class="wp-block-paragraph">Short enough that an engineer waits for it rather than switching context. Somewhere under a couple of minutes for the combined lint, format, and unit stage is a reasonable target for most codebases. Past that, people push and walk away, and you lose the benefit of fast feedback even though the pipeline is technically quick.</p>



<h3 class="wp-block-heading">Does this apply to self-hosted runners too?</h3>



<p class="wp-block-paragraph">All of it, and timeouts matter more. On hosted runners a stuck job eventually hits the platform&#8217;s own ceiling. On your own machine it holds a slot until you intervene, so a hung job blocks the queue for everyone. Set explicit timeouts and make sure the runner cleans up its workspace between jobs.</p>



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



<h2 class="wp-block-heading">The one thing to take away</h2>



<p class="wp-block-paragraph">Speed is the part everyone optimises and honesty is the part that actually costs money. A fail fast pipeline that finishes in ninety seconds is worth very little if one of those seconds contains a swallowed exit code. Fix the lying first: <code>pipefail</code>, tools that return non-zero when they fail, and assertions that check the outcome rather than the request. Then reorder for speed.</p>



<p class="wp-block-paragraph">And judge every failure by one question. Could someone else fix this from the output alone, without reading the source? If the answer is no, the pipeline has not finished its job, no matter how quickly it went red.</p>



<h2 class="wp-block-heading">Need help fixing a pipeline that lies to you?</h2>



<p class="wp-block-paragraph">If any of this sounds like your CI, this is the kind of work I take on. Typical engagements look like:</p>



<ul class="wp-block-list">
<li>Auditing an existing GitHub Actions, GitLab CI, or Jenkins pipeline for silently swallowed failures and false-green stages.</li>
<li>Restructuring stage order and caching so the common failure surfaces in seconds instead of after the build.</li>
<li>Hardening deploy verification: rollout gates, smoke tests through the real public path, and automatic rollback on a failed health check.</li>
<li>Rewriting pipeline shell scripts with proper error handling, exit code contracts, and failure messages an on-call engineer can act on.</li>
<li>Wiring pipeline duration and failure metrics into Prometheus and Grafana so regressions get caught early.</li>
</ul>



<p class="wp-block-paragraph">Send me the workflow file and a link to a failing run, 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/fail-fast-pipeline/">Green Check, Broken Build: How to Build a Fail Fast Pipeline That Explains Why</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>Docker Issues &#038; Troubleshooting: Real-World Solutions</title>
		<link>https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/</link>
		
		<dc:creator><![CDATA[John Nessime]]></dc:creator>
		<pubDate>Wed, 15 Jul 2026 17:05:30 +0000</pubDate>
				<category><![CDATA[Articles]]></category>
		<category><![CDATA[Cloud Computing]]></category>
		<category><![CDATA[DevOps]]></category>
		<category><![CDATA[Docker]]></category>
		<category><![CDATA[Linux]]></category>
		<category><![CDATA[Tutorials]]></category>
		<category><![CDATA[Apache]]></category>
		<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Containers]]></category>
		<category><![CDATA[Docker Compose]]></category>
		<category><![CDATA[Grafana]]></category>
		<category><![CDATA[Infrastructure]]></category>
		<category><![CDATA[Logs]]></category>
		<category><![CDATA[Monitoring]]></category>
		<category><![CDATA[Networking]]></category>
		<category><![CDATA[Nginx]]></category>
		<category><![CDATA[Production]]></category>
		<category><![CDATA[Prometheus]]></category>
		<category><![CDATA[Reverse Proxy]]></category>
		<category><![CDATA[Self Hosting]]></category>
		<category><![CDATA[System Administration]]></category>
		<category><![CDATA[Troubleshooting]]></category>
		<category><![CDATA[Volumes]]></category>
		<guid isPermaLink="false">https://john-nessime.com/blog/?p=10</guid>

					<description><![CDATA[<p>Docker containers rarely fail without leaving clues. This practical guide explores common Docker issues involving networking, storage, permissions, ports, logs, health checks, reverse proxies, and resource usage—and explains how to troubleshoot them methodically.</p>
<p>The post <a href="https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/">Docker Issues &amp; Troubleshooting: Real-World Solutions</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></description>
										<content:encoded><![CDATA[

<p class="article-introduction wp-block-paragraph">Docker feels almost magical when you first start using it. You write a configuration file, run one command, and an entire application stack appears.</p>



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



<p class="wp-block-paragraph">A web application, database, cache, monitoring service, and reverse proxy can all be launched in seconds. Everything is isolated, repeatable, and relatively easy to move between environments.</p>



<p class="wp-block-paragraph">Then, eventually, something breaks.</p>



<p class="wp-block-paragraph">A container refuses to start. A port is already occupied. A database loses access to its storage directory. One service cannot resolve the hostname of another. A reverse proxy returns a 502 error even though the application appears to be running.</p>



<p class="wp-block-paragraph">That is usually the point where Docker stops feeling like a convenient deployment tool and starts becoming a real infrastructure skill.</p>



<p class="wp-block-paragraph">This guide is based on the way I approach Docker issues on real Linux servers. It is not a list of commands to copy blindly. It is a troubleshooting method for discovering what failed, understanding why it failed, and solving it without making the situation worse.</p>



<h2 class="wp-block-heading">Docker Is Usually Not the Real Problem</h2>



<p class="wp-block-paragraph">When a container fails, Docker is often blamed first. In many cases, however, Docker is only exposing a problem somewhere else in the system.</p>



<ul class="wp-block-list">
<li>Incorrect Linux file permissions</li>
<li>A missing environment variable</li>
<li>An unavailable database</li>
<li>A port conflict on the host</li>
<li>An incorrect volume mount</li>
<li>A DNS or network configuration problem</li>
<li>A reverse proxy pointing to the wrong destination</li>
<li>Insufficient memory or disk space</li>
<li>An application-level configuration error</li>
</ul>



<p class="wp-block-paragraph">The container runtime may be working exactly as designed. The application inside the container may simply be unable to operate under the conditions it was given.</p>



<p class="wp-block-paragraph">This distinction matters because repeatedly deleting and rebuilding containers rarely fixes the underlying issue. It may temporarily hide it, but the same failure usually returns.</p>



<h2 class="wp-block-heading">My First Rule of Docker Troubleshooting: Do Not Delete Anything Yet</h2>



<p class="wp-block-paragraph">When a container suddenly exits, the first instinct is often to remove it, rebuild the image, or restart the entire stack. That destroys useful evidence.</p>



<p class="wp-block-paragraph">Before changing anything, I start by collecting information.</p>



<pre class="wp-block-code"><code>docker ps -a</code></pre>



<p class="wp-block-paragraph">This shows running and stopped containers, their current states, exposed ports, names, and recent exit information.</p>



<pre class="wp-block-code"><code>docker logs container-name</code></pre>



<p class="wp-block-paragraph">The logs frequently reveal the immediate cause: a missing file, invalid password, failed database connection, permission error, or malformed configuration.</p>



<pre class="wp-block-code"><code>docker inspect container-name</code></pre>



<p class="wp-block-paragraph">Inspection provides the container&#8217;s network settings, environment variables, volume mounts, restart policy, health status, image information, and runtime configuration.</p>



<p class="wp-block-paragraph">These three commands answer a large percentage of Docker troubleshooting questions before any corrective action is required.</p>



<h2 class="wp-block-heading">Understanding Docker Container States</h2>



<p class="wp-block-paragraph">A container can exist in several states, and each state tells a different story.</p>



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



<p class="wp-block-paragraph">The main process inside the container is active. This does not automatically mean the application is healthy or ready to receive traffic.</p>



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



<p class="wp-block-paragraph">The container&#8217;s main process stopped. It may have completed normally, crashed, or failed during startup. The exit code and logs provide the next clues.</p>



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



<p class="wp-block-paragraph">The container repeatedly starts and fails while Docker applies its restart policy. This commonly indicates an application crash, invalid configuration, inaccessible dependency, or permission problem.</p>



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



<p class="wp-block-paragraph">The container was created but its main process has not started successfully.</p>



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



<p class="wp-block-paragraph">Docker could not properly stop or remove the container. This is less common and may indicate a lower-level runtime, storage, or host issue.</p>



<p class="wp-block-paragraph">Knowing the state prevents random troubleshooting. A networking issue requires a different investigation from a process that exits because of a syntax error.</p>



<h2 class="wp-block-heading">Start With the Container Logs</h2>



<p class="wp-block-paragraph">Logs are normally the fastest route to the cause of a Docker issue.</p>



<pre class="wp-block-code"><code>docker logs --tail 100 container-name</code></pre>



<p class="wp-block-paragraph">This displays the latest 100 lines without flooding the terminal with the container&#8217;s entire history.</p>



<pre class="wp-block-code"><code>docker logs -f container-name</code></pre>



<p class="wp-block-paragraph">The follow option streams new log entries in real time. It is useful while restarting a service or reproducing a problem.</p>



<pre class="wp-block-code"><code>docker logs --since 30m container-name</code></pre>



<p class="wp-block-paragraph">This limits the output to a recent period, which is especially useful for long-running containers.</p>



<p class="wp-block-paragraph">When reading logs, I do not look only at the final line. The true cause often appears several lines earlier. The last error may simply be a consequence of the first failure.</p>



<h2 class="wp-block-heading">Docker Networking: Where Many Problems Begin</h2>



<p class="wp-block-paragraph">Docker networking is one of the most common sources of confusion, especially when several containers need to communicate.</p>



<p class="wp-block-paragraph">Imagine a stack containing:</p>



<ul class="wp-block-list">
<li>Grafana</li>
<li>Prometheus</li>
<li>A web application</li>
<li>MySQL or PostgreSQL</li>
<li>Redis</li>
<li>Nginx or Apache</li>
</ul>



<p class="wp-block-paragraph">Each service may work independently while communication between them fails.</p>



<h3 class="wp-block-heading">The Localhost Mistake</h3>



<p class="wp-block-paragraph">Inside a container, <code>localhost</code> refers to that container itself. It does not refer to the Docker host, and it does not refer to another container.</p>



<p class="wp-block-paragraph">For example, if Grafana needs to connect to Prometheus, the data source address should usually use the Docker Compose service name:</p>



<pre class="wp-block-code"><code>http://prometheus:9090</code></pre>



<p class="wp-block-paragraph">Using the following from inside the Grafana container would normally point back to Grafana itself:</p>



<pre class="wp-block-code"><code>http://localhost:9090</code></pre>



<p class="wp-block-paragraph">This small misunderstanding causes a surprising number of connection failures.</p>



<h3 class="wp-block-heading">Confirm That Containers Share a Network</h3>



<pre class="wp-block-code"><code>docker network ls</code></pre>



<pre class="wp-block-code"><code>docker network inspect network-name</code></pre>



<p class="wp-block-paragraph">The inspection output shows which containers are attached to a network and which IP addresses were assigned.</p>



<p class="wp-block-paragraph">Containers on unrelated networks cannot automatically communicate by service name. They must share a Docker network or use another explicitly configured route.</p>



<h3 class="wp-block-heading">Test Connectivity From Inside the Container</h3>



<pre class="wp-block-code"><code>docker exec -it container-name sh</code></pre>



<p class="wp-block-paragraph">Depending on the image, Bash may be available instead:</p>



<pre class="wp-block-code"><code>docker exec -it container-name bash</code></pre>



<p class="wp-block-paragraph">From inside the container, test name resolution and connectivity using the utilities available in that image:</p>



<pre class="wp-block-code"><code>getent hosts database
curl http://application:8080
wget -qO- http://prometheus:9090/-/ready</code></pre>



<p class="wp-block-paragraph">A successful result confirms that Docker DNS and network routing are functioning. A failed result narrows the investigation considerably.</p>



<h2 class="wp-block-heading">Host Ports and Container Ports Are Different</h2>



<p class="wp-block-paragraph">A Docker Compose port mapping such as the following is read from left to right:</p>



<pre class="wp-block-code"><code>ports:
  - "8080:80"</code></pre>



<p class="wp-block-paragraph">Port <code>8080</code> belongs to the Docker host. Port <code>80</code> belongs to the application inside the container.</p>



<p class="wp-block-paragraph">Traffic sent to the host on port 8080 is forwarded to port 80 inside the container.</p>



<p class="wp-block-paragraph">If another service already occupies host port 8080, Docker cannot bind to it.</p>



<pre class="wp-block-code"><code>ss -tulpn</code></pre>



<p class="wp-block-paragraph">To check a specific port:</p>



<pre class="wp-block-code"><code>ss -tulpn | grep :8080</code></pre>



<p class="wp-block-paragraph">A common mistake is changing the container&#8217;s internal port when only the host-side mapping needs to change.</p>



<p class="wp-block-paragraph">For example, this avoids a conflict while leaving the application untouched:</p>



<pre class="wp-block-code"><code>ports:
  - "8081:80"</code></pre>



<h2 class="wp-block-heading">Published Ports Are Not Required for Container-to-Container Traffic</h2>



<p class="wp-block-paragraph">Containers connected to the same Docker network can usually communicate through their internal ports without publishing those ports to the host.</p>



<p class="wp-block-paragraph">For example, Prometheus can communicate with an exporter on the shared Docker network without exposing that exporter publicly.</p>



<p class="wp-block-paragraph">This reduces unnecessary exposure and produces a cleaner architecture. Publish only the ports that must be reached from the host, a reverse proxy, or an external client.</p>



<h2 class="wp-block-heading">Docker Volumes: Persistent Storage Done Properly</h2>



<p class="wp-block-paragraph">Containers should be treated as disposable. Persistent data should not depend on the writable filesystem inside a container.</p>



<p class="wp-block-paragraph">Without a correctly configured volume, deleting a database container may also delete the database stored inside it.</p>



<h3 class="wp-block-heading">Named Volumes</h3>



<pre class="wp-block-code"><code>services:
  database:
    image: mariadb:latest
    volumes:
      - database-data:/var/lib/mysql

volumes:
  database-data:</code></pre>



<p class="wp-block-paragraph">Docker manages the storage location while the application writes to its expected internal directory.</p>



<h3 class="wp-block-heading">Bind Mounts</h3>



<pre class="wp-block-code"><code>volumes:
  - ./config:/etc/application</code></pre>



<p class="wp-block-paragraph">A bind mount maps a specific host path into the container. It is useful for configuration files, development directories, and data that administrators need to access directly.</p>



<p class="wp-block-paragraph">Bind mounts also introduce more responsibility. The host directory must exist with the correct permissions, ownership, security context, and contents.</p>



<h3 class="wp-block-heading">Inspect Existing Volumes</h3>



<pre class="wp-block-code"><code>docker volume ls</code></pre>



<pre class="wp-block-code"><code>docker volume inspect volume-name</code></pre>



<p class="wp-block-paragraph">Before deleting an apparently unused volume, confirm which application created it and whether it contains irreplaceable data.</p>



<h2 class="wp-block-heading">Permission Problems Are Quiet but Destructive</h2>



<p class="wp-block-paragraph">Permission errors can prevent databases from initializing, web applications from uploading files, monitoring tools from writing data, or services from reading configuration files.</p>



<p class="wp-block-paragraph">The first checks are simple:</p>



<pre class="wp-block-code"><code>ls -lah
stat path-to-file
stat path-to-directory</code></pre>



<p class="wp-block-paragraph">The user inside the container may have a different numeric user ID from the owner of the host directory. The names do not matter as much as the numeric UID and GID.</p>



<pre class="wp-block-code"><code>docker exec container-name id</code></pre>



<p class="wp-block-paragraph">Compare that result with the ownership of the mounted host path.</p>



<p class="wp-block-paragraph">Changing permissions to <code>777</code> may appear to solve the issue, but it is usually an unsafe shortcut rather than a proper solution. Correct ownership and the minimum required permissions are preferable.</p>



<h2 class="wp-block-heading">SELinux Can Affect Docker Bind Mounts</h2>



<p class="wp-block-paragraph">On distributions such as AlmaLinux, Rocky Linux, CentOS Stream, Fedora, and Red Hat Enterprise Linux, standard Unix permissions may look correct while SELinux still blocks access.</p>



<p class="wp-block-paragraph">Docker Compose bind mounts may require an SELinux relabeling option:</p>



<pre class="wp-block-code"><code>volumes:
  - ./data:/var/lib/application:Z</code></pre>



<p class="wp-block-paragraph">The uppercase <code>Z</code> assigns a private SELinux label for the container. A lowercase <code>z</code> is used when the content must be shared between multiple containers.</p>



<p class="wp-block-paragraph">SELinux should not be disabled simply to make a container work. The better approach is to identify the denied access and apply the correct labeling or policy.</p>

<br><img decoding="async" src="https://john-nessime.com/blog/wp-content/uploads/2026/07/Docker-Issues-Troubleshooting-Real-World-Solutions-2.png" alt="Docker Issues &#038; Troubleshooting - Real-World Solutions | John Nessime">
<br>

<h2 class="wp-block-heading">Environment Variables Can Break an Entire Stack</h2>



<p class="wp-block-paragraph">Modern containerized applications depend heavily on environment variables for database credentials, API keys, application URLs, encryption secrets, feature flags, and runtime settings.</p>



<p class="wp-block-paragraph">One missing or incorrectly escaped value can stop a service from starting.</p>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<p class="wp-block-paragraph">This command renders the resolved Docker Compose configuration. It helps reveal missing substitutions, merged settings, malformed values, and unexpected defaults.</p>



<p class="wp-block-paragraph">To inspect the environment available inside a running container:</p>



<pre class="wp-block-code"><code>docker exec container-name env</code></pre>



<p class="wp-block-paragraph">Be careful when sharing this output because it may include passwords, tokens, and private service credentials.</p>



<h2 class="wp-block-heading">Docker Compose Configuration Errors</h2>



<p class="wp-block-paragraph">YAML is readable, but it is sensitive to indentation and structure. A service can be valid YAML while still being configured incorrectly for Docker Compose.</p>



<p class="wp-block-paragraph">Before deploying a change, validate the file:</p>



<pre class="wp-block-code"><code>docker compose config --quiet</code></pre>



<p class="wp-block-paragraph">If the command returns without an error, the configuration is structurally valid.</p>



<p class="wp-block-paragraph">For a complete rendered version:</p>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<p class="wp-block-paragraph">This is especially valuable when Compose files use environment substitutions, overrides, extension fields, or multiple configuration files.</p>



<h2 class="wp-block-heading">Health Checks Reveal More Than Running Status</h2>



<p class="wp-block-paragraph">A container can be running while the application inside it is unusable.</p>



<p class="wp-block-paragraph">A database may still be initializing. A web service may be listening but unable to connect to its database. An API may be running but returning errors for every request.</p>



<p class="wp-block-paragraph">A health check lets Docker test actual application readiness.</p>



<pre class="wp-block-code"><code>healthcheck:
  test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
  interval: 30s
  timeout: 10s
  retries: 3
  start_period: 20s</code></pre>



<p class="wp-block-paragraph">The application should expose a lightweight endpoint that confirms its critical dependencies are available.</p>



<p class="wp-block-paragraph">To inspect the current status:</p>



<pre class="wp-block-code"><code>docker inspect --format='{{json .State.Health}}' container-name</code></pre>



<p class="wp-block-paragraph">A health check does not repair an unhealthy service by itself, but it provides a far more accurate operational signal than process status alone.</p>



<h2 class="wp-block-heading">Restart Policies Can Hide Repeated Failures</h2>



<p class="wp-block-paragraph">Restart policies are useful in production, but they can create the impression that a service is stable when it is actually crashing continuously.</p>



<pre class="wp-block-code"><code>restart: unless-stopped</code></pre>



<p class="wp-block-paragraph">If the application exits every few seconds, Docker restarts it every few seconds. The container may appear in the process list, but the service may never become ready.</p>



<p class="wp-block-paragraph">Check the restart count:</p>



<pre class="wp-block-code"><code>docker inspect --format='{{.RestartCount}}' container-name</code></pre>



<p class="wp-block-paragraph">A rapidly increasing number indicates that the restart policy is masking an unresolved failure.</p>



<h2 class="wp-block-heading">Reverse Proxy Errors: 502, 504, SSL Problems, and Redirect Loops</h2>



<p class="wp-block-paragraph">Reverse proxies such as Nginx, Apache, Traefik, and Caddy often sit between users and containerized applications.</p>



<p class="wp-block-paragraph">When the public website fails, the application container may still be perfectly healthy. The proxy may simply be unable to reach it.</p>



<h3 class="wp-block-heading">Common Reverse Proxy Problems</h3>



<ul class="wp-block-list">
<li>The upstream hostname is incorrect.</li>
<li>The proxy uses the host port when it should use the container port.</li>
<li>The proxy and application do not share a Docker network.</li>
<li>The application listens only on <code>127.0.0.1</code> inside the container.</li>
<li>The proxy forwards HTTP while the backend expects HTTPS.</li>
<li>The application is unaware that the original request used HTTPS.</li>
<li>The public hostname does not match the certificate.</li>
<li>Both the proxy and application force redirects, creating a loop.</li>
</ul>



<p class="wp-block-paragraph">Test the application directly before blaming the proxy:</p>



<pre class="wp-block-code"><code>curl -I http://127.0.0.1:published-port</code></pre>



<p class="wp-block-paragraph">If the application responds directly but fails through the domain, the reverse proxy, firewall, DNS, or TLS configuration becomes the main area of investigation.</p>



<h2 class="wp-block-heading">An Application Must Listen on the Correct Interface</h2>



<p class="wp-block-paragraph">An application inside a container may start successfully while listening only on <code>127.0.0.1</code>. In that situation, Docker&#8217;s network interface may not be able to reach it.</p>



<p class="wp-block-paragraph">Containerized web applications should usually listen on:</p>



<pre class="wp-block-code"><code>0.0.0.0</code></pre>



<p class="wp-block-paragraph">This allows the process to accept connections through the container&#8217;s network interfaces.</p>



<p class="wp-block-paragraph">This setting is commonly controlled by application arguments or environment variables such as <code>HOST</code>, <code>BIND_ADDRESS</code>, or <code>LISTEN_ADDRESS</code>.</p>



<h2 class="wp-block-heading">Resource Exhaustion Can Look Like a Docker Failure</h2>



<p class="wp-block-paragraph">Containers share the CPU, memory, storage, and network resources of the host. One service can consume enough resources to affect every other workload.</p>



<pre class="wp-block-code"><code>docker stats</code></pre>



<p class="wp-block-paragraph">This provides a live view of CPU, memory, network, and block I/O usage for running containers.</p>



<p class="wp-block-paragraph">On the host, I also check:</p>



<pre class="wp-block-code"><code>free -h
df -h
df -i
uptime</code></pre>



<p class="wp-block-paragraph">Disk space is not the only storage limit. A filesystem can also run out of inodes, preventing new files from being created even when gigabytes remain available.</p>



<p class="wp-block-paragraph">Kernel logs may reveal out-of-memory termination:</p>



<pre class="wp-block-code"><code>dmesg | grep -i -E "out of memory|killed process|oom"</code></pre>



<p class="wp-block-paragraph">If the kernel killed the process, the application logs may end suddenly without a useful explanation.</p>



<h2 class="wp-block-heading">Docker Disk Usage Grows Quietly</h2>



<p class="wp-block-paragraph">Old images, stopped containers, build cache, unused volumes, and unbounded logs can gradually consume the host&#8217;s storage.</p>



<pre class="wp-block-code"><code>docker system df</code></pre>



<p class="wp-block-paragraph">For more detail:</p>



<pre class="wp-block-code"><code>docker system df -v</code></pre>



<p class="wp-block-paragraph">Cleanup commands should be used carefully:</p>



<pre class="wp-block-code"><code>docker image prune
docker container prune
docker builder prune</code></pre>



<p class="wp-block-paragraph">A broad command such as <code>docker system prune</code> can remove more than expected. Never add the volume-removal option unless the contents of unused volumes have been verified and backed up.</p>



<h2 class="wp-block-heading">Container Logs Can Fill the Server</h2>



<p class="wp-block-paragraph">Docker&#8217;s default JSON logging driver can create large log files when applications produce continuous output.</p>



<p class="wp-block-paragraph">Log rotation can be configured in Docker Compose:</p>



<pre class="wp-block-code"><code>logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "5"</code></pre>



<p class="wp-block-paragraph">This limits the number and size of local log files for that container.</p>



<p class="wp-block-paragraph">For production systems, centralized logging with tools such as Grafana Loki, Elasticsearch, OpenSearch, or a managed logging service makes incident investigation considerably easier.</p>



<h2 class="wp-block-heading">Image Architecture and Compatibility Problems</h2>



<p class="wp-block-paragraph">An image built for one CPU architecture may not run correctly on another. This becomes relevant when moving between AMD64 servers, ARM-based cloud instances, Apple Silicon machines, and single-board computers.</p>



<p class="wp-block-paragraph">Check the host architecture:</p>



<pre class="wp-block-code"><code>uname -m</code></pre>



<p class="wp-block-paragraph">Inspect the image:</p>



<pre class="wp-block-code"><code>docker image inspect image-name</code></pre>



<p class="wp-block-paragraph">Errors mentioning an invalid executable format frequently indicate an architecture mismatch.</p>



<p class="wp-block-paragraph">Multi-platform builds can be produced using Docker Buildx:</p>



<pre class="wp-block-code"><code>docker buildx build --platform linux/amd64,linux/arm64 .</code></pre>



<h2 class="wp-block-heading">A New Image Tag Does Not Always Mean a Safe Upgrade</h2>



<p class="wp-block-paragraph">Using <code>latest</code> may be convenient, but it makes deployments less predictable. A future image update can introduce a breaking configuration change, database migration, removed feature, or incompatible dependency.</p>



<p class="wp-block-paragraph">Production deployments are usually safer with an explicit version:</p>



<pre class="wp-block-code"><code>image: grafana/grafana:12.0.2</code></pre>



<p class="wp-block-paragraph">Before upgrading:</p>



<ul class="wp-block-list">
<li>Read the release notes.</li>
<li>Confirm supported upgrade paths.</li>
<li>Back up persistent data.</li>
<li>Record the current working version.</li>
<li>Test the change outside production where possible.</li>
<li>Prepare a rollback procedure.</li>
</ul>



<p class="wp-block-paragraph">Containers make software easier to replace, but they do not make every application upgrade reversible.</p>



<h2 class="wp-block-heading">Dependency Startup Order Is Not Readiness</h2>



<p class="wp-block-paragraph">Docker Compose can start services in a defined order, but starting a database container does not mean the database is immediately ready for connections.</p>



<p class="wp-block-paragraph">An application may launch too early, fail to connect, and exit.</p>



<p class="wp-block-paragraph">Health checks and dependency conditions can improve this:</p>



<pre class="wp-block-code"><code>services:
  database:
    image: postgres:17
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  application:
    image: example/application:1.0
    depends_on:
      database:
        condition: service_healthy</code></pre>



<p class="wp-block-paragraph">The application itself should still handle temporary dependency failures gracefully. Networks fluctuate, databases restart, and external services occasionally become unavailable.</p>



<h2 class="wp-block-heading">Backups Matter More Than Recreating Containers</h2>



<p class="wp-block-paragraph">Application containers are normally easy to recreate. Persistent business data is not.</p>



<p class="wp-block-paragraph">A reliable Docker backup strategy should cover:</p>



<ul class="wp-block-list">
<li>Database-native backups</li>
<li>Named volumes and bind-mounted data</li>
<li>Docker Compose files</li>
<li>Environment files and secrets</li>
<li>Reverse proxy configurations</li>
<li>TLS and certificate configuration</li>
<li>Application-specific settings</li>
<li>A tested restoration procedure</li>
</ul>



<p class="wp-block-paragraph">A backup that has never been restored is only an assumption.</p>



<p class="wp-block-paragraph">Database backups should normally use the database&#8217;s own tools rather than copying active database files blindly. Examples include <code>pg_dump</code> for PostgreSQL and <code>mysqldump</code> or <code>mariadb-dump</code> for MySQL-compatible systems.</p>



<h2 class="wp-block-heading">Monitoring Changes Troubleshooting From Guesswork to Evidence</h2>



<p class="wp-block-paragraph">Without monitoring, administrators usually investigate after users report a failure. By then, the container may have restarted and the original conditions may no longer be visible.</p>



<p class="wp-block-paragraph">Metrics help answer better questions:</p>



<ul class="wp-block-list">
<li>When did memory consumption begin increasing?</li>
<li>Which container experienced the first error?</li>
<li>Was the disk already nearly full?</li>
<li>Did network latency rise before the application failed?</li>
<li>How many times did the service restart?</li>
<li>Did the host run out of memory?</li>
<li>Was the reverse proxy still able to reach the backend?</li>
</ul>



<p class="wp-block-paragraph">Prometheus, Grafana, node-level exporters, container metrics, uptime probes, and centralized logs provide the historical context that individual Docker commands cannot.</p>



<h2 class="wp-block-heading">A Practical Docker Troubleshooting Workflow</h2>



<p class="wp-block-paragraph">When a Docker service fails, I use a sequence rather than jumping between unrelated commands.</p>



<h3 class="wp-block-heading">1. Identify the Exact Symptom</h3>



<ul class="wp-block-list">
<li>Does the container fail to start?</li>
<li>Does it start and then exit?</li>
<li>Is it running but unreachable?</li>
<li>Is the application responding with errors?</li>
<li>Is the public domain failing while the local service works?</li>
<li>Is the problem intermittent?</li>
</ul>



<h3 class="wp-block-heading">2. Check Container Status</h3>



<pre class="wp-block-code"><code>docker ps -a</code></pre>



<h3 class="wp-block-heading">3. Read the Logs</h3>



<pre class="wp-block-code"><code>docker logs --tail 200 container-name</code></pre>



<h3 class="wp-block-heading">4. Inspect Runtime Configuration</h3>



<pre class="wp-block-code"><code>docker inspect container-name</code></pre>



<h3 class="wp-block-heading">5. Validate Docker Compose</h3>



<pre class="wp-block-code"><code>docker compose config</code></pre>



<h3 class="wp-block-heading">6. Check Host Resources</h3>



<pre class="wp-block-code"><code>docker stats
free -h
df -h
df -i</code></pre>



<h3 class="wp-block-heading">7. Test Internal Connectivity</h3>



<p class="wp-block-paragraph">Enter the relevant container and test DNS resolution, ports, and HTTP endpoints from the same network context as the application.</p>



<h3 class="wp-block-heading">8. Check Volumes and Permissions</h3>



<p class="wp-block-paragraph">Confirm that mounts point to the expected locations and that the container user can read or write them as required.</p>



<h3 class="wp-block-heading">9. Isolate the Reverse Proxy</h3>



<p class="wp-block-paragraph">Test the backend directly. If the backend works, investigate the proxy, DNS, TLS, firewall, and forwarded headers.</p>



<h3 class="wp-block-heading">10. Change One Thing at a Time</h3>



<p class="wp-block-paragraph">Multiple simultaneous changes make it difficult to identify the actual solution. Apply one correction, test it, and record the result.</p>



<h2 class="wp-block-heading">Commands I Frequently Use During Docker Investigations</h2>



<pre class="wp-block-code"><code># Show running containers
docker ps

# Show all containers
docker ps -a

# Read recent logs
docker logs --tail 100 container-name

# Follow logs
docker logs -f container-name

# Inspect a container
docker inspect container-name

# Enter a container
docker exec -it container-name sh

# View live resource use
docker stats

# List networks
docker network ls

# Inspect a network
docker network inspect network-name

# List volumes
docker volume ls

# Inspect a volume
docker volume inspect volume-name

# Validate Compose configuration
docker compose config

# View Compose service status
docker compose ps

# Restart one service
docker compose restart service-name

# Recreate one service
docker compose up -d --force-recreate service-name

# View Docker disk usage
docker system df

# View host listening ports
ss -tulpn

# Check disk usage
df -h

# Check inode usage
df -i

# Check memory
free -h</code></pre>



<h2 class="wp-block-heading">Docker Troubleshooting Mistakes to Avoid</h2>



<h3 class="wp-block-heading">Deleting Containers Before Reading Their Logs</h3>



<p class="wp-block-paragraph">This removes evidence and often leaves the original problem unresolved.</p>



<h3 class="wp-block-heading">Using Docker System Prune Without Reviewing the Impact</h3>



<p class="wp-block-paragraph">Cleanup commands can remove useful images, build cache, stopped containers, and potentially important storage.</p>



<h3 class="wp-block-heading">Using Latest Everywhere</h3>



<p class="wp-block-paragraph">Uncontrolled image updates reduce reproducibility and can introduce breaking changes unexpectedly.</p>



<h3 class="wp-block-heading">Opening Every Port Publicly</h3>



<p class="wp-block-paragraph">Internal services should remain internal unless external access is genuinely required.</p>



<h3 class="wp-block-heading">Setting Permissions to 777</h3>



<p class="wp-block-paragraph">This weakens security and avoids fixing the actual ownership or access-control issue.</p>



<h3 class="wp-block-heading">Restarting the Entire Server Too Early</h3>



<p class="wp-block-paragraph">A restart may temporarily restore service while destroying evidence about the original failure.</p>



<h3 class="wp-block-heading">Changing Several Variables at Once</h3>



<p class="wp-block-paragraph">Even when the issue disappears, it becomes impossible to know which change solved it.</p>



<h2 class="wp-block-heading">Docker Compose Is Also Infrastructure Documentation</h2>



<p class="wp-block-paragraph">A well-organized Compose file documents the structure of an application environment.</p>



<ul class="wp-block-list">
<li>Which services exist</li>
<li>Which image versions are deployed</li>
<li>Which networks connect them</li>
<li>Which ports are externally accessible</li>
<li>Where persistent data is stored</li>
<li>Which environment variables are required</li>
<li>Which services depend on others</li>
<li>Which health checks are available</li>
<li>How containers restart after failure</li>
</ul>



<p class="wp-block-paragraph">Six months after a deployment, this information is often more useful than a separate document that was never updated.</p>



<p class="wp-block-paragraph">Readable service names, comments for non-obvious decisions, pinned image versions, organized environment files, and clear volume names make future troubleshooting much faster.</p>



<h2 class="wp-block-heading">What Docker Failures Actually Teach</h2>



<p class="wp-block-paragraph">Some of the most useful infrastructure lessons come from deployments that did not work as expected.</p>



<p class="wp-block-paragraph">A failed container may teach you how Linux permissions interact with numeric user IDs. A 502 error may reveal the difference between host ports and container ports. A disappearing database may demonstrate why persistent volumes matter. An unreachable service may expose a misunderstanding about Docker DNS or network boundaries.</p>



<p class="wp-block-paragraph">These incidents are frustrating, but they build practical knowledge that clean tutorial environments rarely provide.</p>



<p class="wp-block-paragraph">Production systems do not normally fail in textbook ways. Several small issues may combine: a nearly full disk, oversized logs, an automatic image upgrade, and an aggressive restart policy can create one confusing incident.</p>



<h2 class="wp-block-heading">Final Thoughts</h2>



<p class="wp-block-paragraph">Docker makes application deployment more portable, repeatable, and manageable, but it does not remove the need to understand the systems underneath it.</p>



<p class="wp-block-paragraph">Effective Docker troubleshooting requires knowledge of Linux, networking, storage, process management, application configuration, security, and monitoring.</p>



<p class="wp-block-paragraph">The most important habit is simple: follow the evidence.</p>



<p class="wp-block-paragraph">Check the container state. Read the logs. Inspect the configuration. Test the network from the correct location. Verify volumes and permissions. Check the host&#8217;s resources. Separate the application from the proxy. Change one thing at a time.</p>



<p class="wp-block-paragraph">The next time a Docker container fails, resist the urge to delete everything and begin again. The answer is usually already present in the logs, runtime configuration, network design, storage mapping, or host environment.</p>



<p class="wp-block-paragraph">Docker troubleshooting becomes much less intimidating once every failure is treated as an investigation rather than an emergency.</p>



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



<h2 class="wp-block-heading">Frequently Asked Questions About Docker Issues</h2>



<h3 class="wp-block-heading">Why does my Docker container exit immediately?</h3>



<p class="wp-block-paragraph">A Docker container exits when its main process stops. Common causes include invalid configuration, missing environment variables, permission errors, failed dependency connections, and incorrect startup commands. Use <code>docker logs</code> and <code>docker inspect</code> to identify the cause.</p>



<h3 class="wp-block-heading">Why is my Docker container running but not accessible?</h3>



<p class="wp-block-paragraph">The application may be listening on the wrong interface, the port may not be published correctly, a firewall may be blocking traffic, or the reverse proxy may point to the wrong hostname or port. Test the application both inside the container and directly through the host port.</p>



<h3 class="wp-block-heading">Why can one Docker container not connect to another?</h3>



<p class="wp-block-paragraph">The containers may not share a Docker network, the wrong service name may be used, or the destination service may not be listening on the expected port. Inside Docker, use the Compose service name rather than <code>localhost</code>.</p>



<h3 class="wp-block-heading">How do I find which process is using a Docker port?</h3>



<p class="wp-block-paragraph">Run <code>ss -tulpn</code> on the Docker host and filter for the relevant port. If the host port is already occupied, change the host side of the Docker port mapping or stop the conflicting service.</p>



<h3 class="wp-block-heading">Can restarting Docker solve container problems?</h3>



<p class="wp-block-paragraph">Restarting Docker may temporarily restore a service, but it can also hide the original cause. Logs, container state, disk space, memory, network connectivity, and volume permissions should be checked before restarting the Docker daemon or server.</p>



<h3 class="wp-block-heading">How can I prevent Docker logs from filling the disk?</h3>



<p class="wp-block-paragraph">Configure log rotation using the Docker logging options, such as <code>max-size</code> and <code>max-file</code>. Production environments may also benefit from centralized logging with Grafana Loki, OpenSearch, Elasticsearch, or a managed logging platform.</p>



<h3 class="wp-block-heading">Should I use the latest Docker image tag?</h3>



<p class="wp-block-paragraph">The latest tag is convenient but unpredictable. Pinning a tested image version improves repeatability and makes upgrades and rollbacks easier to control.</p>



<h3 class="wp-block-heading">Are Docker volumes automatically backed up?</h3>



<p class="wp-block-paragraph">No. Docker volumes provide persistent storage, but Docker does not automatically create external backups. Important volumes and databases need a separate, tested backup and restoration process.</p>

<p>The post <a href="https://john-nessime.com/blog/devops/docker-issues-troubleshooting-real-world-solutions/">Docker Issues &amp; Troubleshooting: Real-World Solutions</a> appeared first on <a href="https://john-nessime.com/blog">John Nessime</a>.</p>
]]></content:encoded>
					
		
		
			</item>
	</channel>
</rss>
