<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <title>ddz.dev LLC</title>
    <subtitle>Notes on web development, infrastructure, and building 12 projects in 12 months.</subtitle>
    <link href="https://ddz.dev/feed.xml" rel="self"/>
    <link href="https://ddz.dev/blog"/>
    <id>https://ddz.dev/</id>
    <updated>2026-07-23T00:00:00.000Z</updated>
    <author>
        <name>Dušan Dželebdžić</name>
    </author>
    <entry>
        <title>Symfony 8 ignores your #[Groups] attribute</title>
        <link href="https://ddz.dev/blog/symfony-8-groups-attribute-silently-ignored/"/>
        <id>https://ddz.dev/blog/symfony-8-groups-attribute-silently-ignored/</id>
        <updated>2026-07-23T00:00:00.000Z</updated>
        <summary>After a Symfony 8 upgrade the serializer started returning empty objects. The old Annotation&#92;Groups import compiles but is silently ignored. One-line fix.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Fix (TL;DR)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;Grep your codebase: &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;grep -rn &#39;Serializer&#92;&#92;Annotation&#39; src/&lt;/code&gt;&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;Replace every import with &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;Symfony&#92;Component&#92;Serializer&#92;Attribute&#92;...&lt;/code&gt;&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;Clear the cache: &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;php bin/console cache:clear&lt;/code&gt;&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;Your objects serialize with their fields again.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;I upgraded a client&#39;s Symfony app to Symfony 8 recently. The upgrade itself went smoothly: dependencies resolved, the app booted, the test suite was green. A few days later a bug report came in that the invoice history was broken. Customers opened a PDF of an old invoice and got the letterhead, the logo, the table header, and then nothing. No line items, no totals. A beautifully rendered blank invoice.&lt;/p&gt;
&lt;p&gt;The PDF is drawn client-side from a JSON endpoint, so I checked the API response first. The endpoint was returning HTTP 200 and a perfectly well-formed payload:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token property&quot;&gt;&quot;contents&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four line items, and every single one of them serialized as an empty object. The entity was fine. The database rows were fine. The controller was fine. The serializer just looked at each object, found nothing it considered worth serializing, and cheerfully returned &lt;code&gt;{}&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;The import that lies to you&lt;/h2&gt;
&lt;p&gt;The line items were annotated the way Symfony code has been annotated for years:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;Symfony&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Component&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Serializer&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Annotation&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Groups&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;br&gt;&lt;br&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;InvoiceLineItem&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token attribute&quot;&gt;&lt;span class=&quot;token delimiter punctuation&quot;&gt;#[&lt;/span&gt;&lt;span class=&quot;token attribute-content&quot;&gt;&lt;span class=&quot;token attribute-class-name class-name&quot;&gt;Groups&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string single-quoted-string&quot;&gt;&#39;invoice&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;token delimiter punctuation&quot;&gt;]&lt;/span&gt;&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token keyword&quot;&gt;private&lt;/span&gt; &lt;span class=&quot;token keyword type-declaration&quot;&gt;string&lt;/span&gt; &lt;span class=&quot;token variable&quot;&gt;$description&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Spot the bug? Neither did I, for longer than I&#39;d like to admit. The &lt;code&gt;#[Groups]&lt;/code&gt; attribute is right there. It compiles. Static analysis was quiet. The app had shipped hundreds of invoices with exactly this code.&lt;/p&gt;
&lt;p&gt;The problem is the namespace. Symfony moved serializer attributes from &lt;code&gt;Serializer&#92;Annotation&lt;/code&gt; to &lt;code&gt;Serializer&#92;Attribute&lt;/code&gt; back in 6.4, and kept the old namespace around as a deprecation shim. Symfony 8 pulled the plug, and it did it in a particularly sneaky way. The shim file still exists, but the class inside it is declared like this:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token constant boolean&quot;&gt;false&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;token class-name-definition class-name&quot;&gt;Groups&lt;/span&gt; &lt;span class=&quot;token keyword&quot;&gt;extends&lt;/span&gt; &lt;span class=&quot;token class-name class-name-fully-qualified&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Symfony&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Component&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Serializer&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Attribute&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Groups&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;if (false)&lt;/code&gt; block means IDEs and static analysis tools still see a class there, but at runtime the class simply does not exist. Loading the file pulls in the new &lt;code&gt;Attribute&#92;Groups&lt;/code&gt; and nothing else.&lt;/p&gt;
&lt;h2&gt;Why PHP never says a word&lt;/h2&gt;
&lt;p&gt;Here&#39;s the part that turns a removed class into a silent failure instead of a fatal error. PHP attributes are lazy. &lt;code&gt;#[Groups([&#39;invoice&#39;])]&lt;/code&gt; isn&#39;t executed when the file loads; it&#39;s stored as metadata, a class name and some arguments, and nothing tries to resolve that class name until someone calls reflection and asks for an instance.&lt;/p&gt;
&lt;p&gt;Symfony&#39;s attribute loader walks the reflection metadata looking for &lt;em&gt;its&lt;/em&gt; attribute classes, the ones in &lt;code&gt;Serializer&#92;Attribute&lt;/code&gt;. Your attribute names a class from &lt;code&gt;Serializer&#92;Annotation&lt;/code&gt;, which is not on that list and doesn&#39;t even exist. So the loader does the only reasonable thing it can do with an attribute it doesn&#39;t recognize: it skips it. That&#39;s correct behavior, by the way. Your entity might carry attributes from Doctrine, from a validation library, from your own code. The serializer can&#39;t throw on every attribute that isn&#39;t meant for it.&lt;/p&gt;
&lt;p&gt;The result is a perfect storm of nobody-is-wrong. PHP is right not to error on an unresolved attribute class. The serializer is right to ignore attributes it doesn&#39;t own. The shim is right to exist so old code doesn&#39;t fatal on load. And you&#39;re left with an entity whose serialization groups have quietly evaporated, so &lt;code&gt;normalize($item, null, [&#39;groups&#39; =&amp;gt; &#39;invoice&#39;])&lt;/code&gt; matches zero properties and hands you an empty object.&lt;/p&gt;
&lt;p&gt;No exception. No deprecation notice, because the deprecation period is over. Not even a log line.&lt;/p&gt;
&lt;h2&gt;The fix&lt;/h2&gt;
&lt;p&gt;One line per file:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;Symfony&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Component&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Serializer&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Attribute&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Groups&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s it. The attribute usage stays identical, only the import changes. Find every affected file with:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-rn&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&#39;Serializer&#92;&#92;Annotation&#39;&lt;/span&gt; src/&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Fix them all in one pass, because this fails per-class: one entity on the new namespace and one on the old gives you a response where half the graph is populated and half is empty objects, which is even more confusing to debug. Then clear the cache, since serializer metadata is cached and will happily keep serving the old, empty mapping:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php bin/console cache:clear&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The invoices came back immediately, line items and all.&lt;/p&gt;
&lt;h2&gt;The rest of the minefield&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;Groups&lt;/code&gt; is not the only class that moved. The same shim treatment applies to the whole old namespace: &lt;code&gt;SerializedName&lt;/code&gt;, &lt;code&gt;Ignore&lt;/code&gt;, &lt;code&gt;Context&lt;/code&gt;, &lt;code&gt;MaxDepth&lt;/code&gt;, &lt;code&gt;DiscriminatorMap&lt;/code&gt;. Every one of them will compile from the old import and do absolutely nothing. &lt;code&gt;Ignore&lt;/code&gt; is the scary one, since a silently dropped &lt;code&gt;#[Ignore]&lt;/code&gt; doesn&#39;t remove data from your response, it &lt;em&gt;adds&lt;/em&gt; data you meant to hide. Empty invoices are embarrassing; leaking a field you explicitly marked as ignored is a different category of morning.&lt;/p&gt;
&lt;p&gt;The same annotation-to-attribute migration happened in Doctrine and in the Validator component, so if your upgrade path skipped a major version or two, grep for those old namespaces while you&#39;re at it.&lt;/p&gt;
&lt;p&gt;After I published this, a commenter on Reddit suggested Rector would have caught it, and the &lt;a href=&quot;https://symfony.com/doc/current/setup/upgrade_major.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;official upgrade guide&lt;/a&gt; does tip Rector right in the fix-your-deprecations step. That stung enough that I went and tested it: a scratch project with an entity reproducing the exact broken pattern (attribute syntax, imports from the old namespace) and every Symfony upgrade set from 6.0 through 8.1, plus the annotations-to-attributes set.&lt;/p&gt;
&lt;p&gt;Zero changes. Rector reported the file clean.&lt;/p&gt;
&lt;p&gt;The packaged sets convert docblock annotations to attributes. Code that&#39;s already on attribute syntax with the old imports sails through untouched. Rector can fix this, but only if you point it at the problem yourself:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token keyword&quot;&gt;use&lt;/span&gt; &lt;span class=&quot;token package&quot;&gt;Rector&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Renaming&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Rector&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;Name&lt;span class=&quot;token punctuation&quot;&gt;&#92;&lt;/span&gt;RenameClassRector&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;br&gt;&lt;br&gt;&lt;span class=&quot;token operator&quot;&gt;-&gt;&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;withConfiguredRule&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token class-name static-context&quot;&gt;RenameClassRector&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;::&lt;/span&gt;&lt;span class=&quot;token keyword&quot;&gt;class&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token string single-quoted-string&quot;&gt;&#39;Symfony&#92;Component&#92;Serializer&#92;Annotation&#92;Groups&#39;&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;=&gt;&lt;/span&gt; &lt;span class=&quot;token string single-quoted-string&quot;&gt;&#39;Symfony&#92;Component&#92;Serializer&#92;Attribute&#92;Groups&#39;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token comment&quot;&gt;// ...same for Ignore, SerializedName, Context, MaxDepth, DiscriminatorMap&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That works; I watched it rewrite my test entity. So the honest summary is worse than I thought when I started writing this section: static analysis is blind to this by design, and the refactoring tool everyone recommends passes it by default. The one command that reliably shows the truth is &lt;code&gt;bin/console debug:serializer App&#92;Entity&#92;Whatever&lt;/code&gt;, which prints the metadata the serializer actually sees. My missing groups would have been plainly visible there.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;After a Symfony major upgrade, green tests and a booting app don&#39;t prove your attributes are still being read. Anything that configures behavior through attributes can degrade into a no-op instead of an error, because unknown attributes are skipped by design. Grep for the old namespaces before you trust the output, and be suspicious of any API response that&#39;s the right shape but strangely empty. The serializer isn&#39;t broken. It&#39;s doing exactly what your imports told it to do, which is nothing.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Upgrading a Symfony app and hitting weirdness like this?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Send me the details&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>The database was fine. The cloud around it wasn&#39;t.</title>
        <link href="https://ddz.dev/blog/azure-managed-postgres-connection/"/>
        <id>https://ddz.dev/blog/azure-managed-postgres-connection/</id>
        <updated>2026-07-13T00:00:00.000Z</updated>
        <summary>Azure&#39;s managed PostgreSQL was fine the whole time. The private DNS zone and SSL defaults around it ate half a day. Where &quot;managed&quot; stops and your job starts.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Fix (TL;DR)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;Getting &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;could not translate host name&lt;/code&gt;? Your Private DNS Zone isn&#39;t linked to the VNet your app runs in. Link it and the hostname exists again&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;DNS resolves but the connection still dies? Add &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;sslmode=require&lt;/code&gt; to the connection string. Azure won&#39;t talk plaintext&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;Still stuck? Check the server&#39;s firewall rules and confirm which VNet your app is actually deployed into&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;The database was never broken. Debug the cloud around it.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;I deployed an application against &lt;strong&gt;Azure Database for PostgreSQL Flexible Server&lt;/strong&gt; recently, and the database itself was never the problem. PostgreSQL was up, healthy, and happily accepting connections the entire time. The cloud wrapped around it was another story, and it cost me the better part of a day.&lt;/p&gt;
&lt;p&gt;Every provider makes the same promise: &lt;em&gt;&amp;quot;it&#39;s managed.&amp;quot;&lt;/em&gt; What the promise quietly leaves out is the networking, the DNS, the TLS defaults, the firewall rules, and the three different portal blades you&#39;ll need to understand before your app gets to say hello to its own database.&lt;/p&gt;
&lt;h2&gt;The usual checklist ran out fast&lt;/h2&gt;
&lt;p&gt;The app kept failing to connect. Nothing exotic, just a timeout. The connection string looked fine: host, user, dbname, password, all correct. So I ran through the standard suspects. Firewall rules? Fine. Credentials? Correct. Is the server actually running? Yes, Azure swears it is.&lt;/p&gt;
&lt;p&gt;That&#39;s normally where this kind of story ends. Someone fat-fingered a password, you fix it, you move on. Not this time. The checklist ran out and the timeout stayed.&lt;/p&gt;
&lt;h2&gt;The hostname that didn&#39;t exist&lt;/h2&gt;
&lt;p&gt;The server was configured for &lt;strong&gt;private access&lt;/strong&gt;, which sounds like the simple option. It isn&#39;t. Azure doesn&#39;t just hand your database a private IP and call it a day. It expects you to understand &lt;strong&gt;Azure Private DNS Zones&lt;/strong&gt;, because the server&#39;s hostname only resolves through a zone like &lt;code&gt;privatelink.postgres.database.azure.com&lt;/code&gt;, and that zone has to be explicitly linked to your virtual network.&lt;/p&gt;
&lt;p&gt;If the link is missing, the hostname doesn&#39;t resolve to a wrong address or a public one. It simply doesn&#39;t exist:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;could not translate host name&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;At that point you&#39;re not debugging PostgreSQL anymore. You&#39;re debugging Azure&#39;s networking model, from inside an application that can only tell you &lt;em&gt;&amp;quot;cannot connect to database.&amp;quot;&lt;/em&gt; The fix was linking the Private DNS Zone to the VNet the app actually runs in. One checkbox, buried two portals away from anything labeled &amp;quot;PostgreSQL.&amp;quot;&lt;/p&gt;
&lt;h2&gt;Thirty minutes for one line&lt;/h2&gt;
&lt;p&gt;With DNS sorted, the connection still failed, because Azure requires TLS and my client wasn&#39;t offering it. This is where PostgreSQL client libraries get to have opinions. Some try SSL first and fall back. Some don&#39;t try at all. Some negotiate, some fail on the spot. The server isn&#39;t broken and the client isn&#39;t broken; their defaults just don&#39;t agree.&lt;/p&gt;
&lt;p&gt;The entire fix was one parameter:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;sslmode=require&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;One line. Thirty minutes of investigation. That ratio is the signature of cloud debugging.&lt;/p&gt;
&lt;h2&gt;&amp;quot;Managed&amp;quot; means the complexity moved&lt;/h2&gt;
&lt;p&gt;Here&#39;s the part I actually find interesting. People assume a managed service means less complexity. It usually means the complexity relocated. Run PostgreSQL yourself and you own backups, upgrades, replication, and storage. Hand it to Azure and those genuinely disappear from your plate, which is great. But now you own VNets, private endpoints, DNS zones, firewall rules, and managed identities instead. The database becomes the easy part.&lt;/p&gt;
&lt;p&gt;And this isn&#39;t an Azure problem. AWS does the same thing, Google Cloud does the same thing. Cloud providers are very good at hiding infrastructure. They&#39;re much less good at hiding distributed systems, and eventually every abstraction leaks: a DNS zone here, a security group there, a certificate that was supposed to be someone else&#39;s job.&lt;/p&gt;
&lt;p&gt;The tradeoff is still often worth making. I&#39;d rather debug a DNS zone link once than babysit replication forever. But pretending the complexity evaporated helps nobody, least of all the person staring at a connection timeout.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;The SQL worked, the schema worked, authentication worked. Every single issue lived outside PostgreSQL, in services I didn&#39;t know I was operating until they failed. So now, whenever someone tells me &lt;em&gt;&amp;quot;don&#39;t worry, it&#39;s managed,&amp;quot;&lt;/em&gt; my next question is: &lt;em&gt;&amp;quot;which part isn&#39;t?&amp;quot;&lt;/em&gt; That&#39;s where the next four hours usually go. The next time a managed service refuses to work, don&#39;t assume your application is broken. The bug may be sitting in a DNS zone you didn&#39;t know existed.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Fighting a cloud setup that refuses to connect?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Send me the details&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Tailscale SSH fails with &quot;tailnet policy does not permit access&quot;</title>
        <link href="https://ddz.dev/blog/tailscale-ssh-tailnet-policy/"/>
        <id>https://ddz.dev/blog/tailscale-ssh-tailnet-policy/</id>
        <updated>2026-06-20T00:00:00.000Z</updated>
        <summary>Tailscale SSH refuses the connection even though the node is online and regular SSH works? Tailnet connectivity and SSH access are separate. Here&#39;s the fix.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Fix (TL;DR)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;This is the SSH policy, not the network. If &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;tailscale ping&lt;/code&gt; works but &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;tailscale ssh&lt;/code&gt; doesn&#39;t, stop debugging connectivity&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;Enable Tailscale SSH on the target machine: &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;sudo tailscale up --ssh&lt;/code&gt;&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;Add an &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;&quot;ssh&quot;&lt;/code&gt; block to your tailnet ACL whose &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;src&lt;/code&gt;, &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;dst&lt;/code&gt;, and &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;users&lt;/code&gt; actually cover you&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;Make sure the login user matches. nonroot rules won&#39;t let you in as root.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;Tailscale is one of those tools that, when it works, makes you wonder what you were doing with your life beforehand. You install it on two machines, log in on both, and the private network that used to take an afternoon of firewall rules and key juggling just appears. Servers are reachable. SSH stops being a chore. Infrastructure becomes boring, which is the highest compliment I can pay it.&lt;/p&gt;
&lt;p&gt;Then one day I ran &lt;code&gt;tailscale ssh&lt;/code&gt; against a box I&#39;d connected a hundred times, and got this:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;tailnet policy does not permit access&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The machine was online. Regular SSH still worked. &lt;code&gt;tailscale ping&lt;/code&gt; came back clean. The node looked perfectly healthy from every angle I could think to check. And the error reads like a networking problem, so naturally I spent the first ten minutes debugging the network, which was fine the whole time.&lt;/p&gt;
&lt;h2&gt;Tailnet access and SSH access aren&#39;t the same thing&lt;/h2&gt;
&lt;p&gt;This is the bit that took me a minute to internalise. Being reachable on your tailnet and being allowed to &lt;code&gt;tailscale ssh&lt;/code&gt; in are governed by two completely separate things. The first is your regular ACL connectivity. The second is a dedicated &lt;code&gt;ssh&lt;/code&gt; section in your tailnet policy that nothing else touches.&lt;/p&gt;
&lt;p&gt;So you can sit in a state where the box pings, MagicDNS resolves it, plain old &lt;code&gt;ssh user@host&lt;/code&gt; over the tailnet works, and &lt;code&gt;tailscale ssh user@host&lt;/code&gt; still slams the door. Everything points at the network because the network is the obvious suspect. The network is innocent.&lt;/p&gt;
&lt;h2&gt;The actual cause&lt;/h2&gt;
&lt;p&gt;In my case the policy simply had no SSH rules at all. Tailscale&#39;s default posture is to deny &lt;code&gt;tailscale ssh&lt;/code&gt; unless a rule explicitly permits it, so an empty &lt;code&gt;ssh&lt;/code&gt; section (or a missing one) means every attempt comes back as &lt;em&gt;&amp;quot;tailnet policy does not permit access.&amp;quot;&lt;/em&gt; It isn&#39;t refusing you because something&#39;s broken. It&#39;s refusing you because you never told it not to.&lt;/p&gt;
&lt;p&gt;The fix is adding a rule to the &lt;code&gt;ssh&lt;/code&gt; block in your tailnet policy:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;  &lt;span class=&quot;token property&quot;&gt;&quot;ssh&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token punctuation&quot;&gt;{&lt;/span&gt;&lt;br&gt;      &lt;span class=&quot;token property&quot;&gt;&quot;action&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token string&quot;&gt;&quot;accept&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;&lt;br&gt;      &lt;span class=&quot;token property&quot;&gt;&quot;src&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;autogroup:members&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;&lt;br&gt;      &lt;span class=&quot;token property&quot;&gt;&quot;dst&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;autogroup:self&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;,&lt;/span&gt;&lt;br&gt;      &lt;span class=&quot;token property&quot;&gt;&quot;users&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;autogroup:nonroot&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;br&gt;    &lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;br&gt;  &lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;br&gt;&lt;span class=&quot;token punctuation&quot;&gt;}&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That one says: any member of the tailnet can SSH into devices they own, as any non-root user. Adjust &lt;code&gt;src&lt;/code&gt;, &lt;code&gt;dst&lt;/code&gt;, and &lt;code&gt;users&lt;/code&gt; to taste, but the shape matters more than the specifics. No rule, no access.&lt;/p&gt;
&lt;h2&gt;You also have to enable SSH on the target&lt;/h2&gt;
&lt;p&gt;There&#39;s a second trap waiting even after the policy is right. The machine you&#39;re connecting to has to be running with Tailscale SSH switched on, and it isn&#39;t on by default. You turn it on with:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;sudo&lt;/span&gt; tailscale up &lt;span class=&quot;token parameter variable&quot;&gt;--ssh&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can confirm the state with &lt;code&gt;tailscale status&lt;/code&gt;. If SSH isn&#39;t enabled on the node itself, the policy can be flawless and you&#39;ll still get nowhere, because there&#39;s nothing on the other end listening for Tailscale to hand the connection to.&lt;/p&gt;
&lt;h2&gt;The root vs nonroot gotcha&lt;/h2&gt;
&lt;p&gt;This one&#39;s subtle enough that it deserves its own warning. Look again at that rule:&lt;/p&gt;
&lt;pre class=&quot;language-json&quot;&gt;&lt;code class=&quot;language-json&quot;&gt;&lt;span class=&quot;token property&quot;&gt;&quot;users&quot;&lt;/span&gt;&lt;span class=&quot;token operator&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;token punctuation&quot;&gt;[&lt;/span&gt;&lt;span class=&quot;token string&quot;&gt;&quot;autogroup:nonroot&quot;&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;]&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It does exactly what it says. If you&#39;ve been testing with:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;tailscale &lt;span class=&quot;token function&quot;&gt;ssh&lt;/span&gt; root@server&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;it&#39;ll keep failing, and you&#39;ll keep staring at a policy that looks correct, because by that policy root genuinely isn&#39;t allowed. Most examples you&#39;ll copy off the internet exclude root on purpose, which is the sensible default. It&#39;s also the easiest thing in the world to forget while you&#39;re three coffees deep trying to work out why a &amp;quot;working&amp;quot; config won&#39;t let you in.&lt;/p&gt;
&lt;h2&gt;What I check now, in order&lt;/h2&gt;
&lt;p&gt;After getting bitten by this, I have a short mental checklist that catches it almost every time. Is the node online? Does &lt;code&gt;tailscale ping&lt;/code&gt; reach it? Is SSH actually enabled on the target with &lt;code&gt;--ssh&lt;/code&gt;? Does the tailnet policy contain an &lt;code&gt;ssh&lt;/code&gt; section that covers this connection? And is the user I&#39;m logging in as allowed by that rule, root included? The answer is usually sitting in step three or step five, and the network at the top of the list is almost never the culprit.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;When &lt;code&gt;tailscale ssh&lt;/code&gt; says the tailnet policy doesn&#39;t permit access, resist the urge to debug the network. Connectivity and SSH authorisation are separate systems in Tailscale, and this error lives entirely in the second one. Enable SSH on the target, make sure your policy has an &lt;code&gt;ssh&lt;/code&gt; rule that matches both the device and the login user, and the door opens. If the message instead said &lt;em&gt;&amp;quot;you&#39;re connected, but the SSH policy denies this&amp;quot;&lt;/em&gt;, I&#39;d have got there a lot faster. Maybe one day it will.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Wrestling with a tailnet ACL or some other piece of weird infrastructure?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Tell me what&#39;s going on&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Modernization usually means rewriting everything wrong</title>
        <link href="https://ddz.dev/blog/modernization-usually-means-rewriting-wrong/"/>
        <id>https://ddz.dev/blog/modernization-usually-means-rewriting-wrong/</id>
        <updated>2026-06-15T00:00:00.000Z</updated>
        <summary>Most legacy modernization projects fail because they rewrite a working system instead of understanding it. The real risk was never the old code.</summary>
        <content type="html">&lt;p&gt;There&#39;s a particular meeting I&#39;ve been in more than once. Someone opens a slide deck, a system that&#39;s been quietly running payroll for twenty years gets labeled &lt;em&gt;&amp;quot;legacy&amp;quot;&lt;/em&gt;, and a room full of people who have never read a line of its code start nodding along to words like &lt;em&gt;modernization&lt;/em&gt;, &lt;em&gt;cloud-native&lt;/em&gt;, and &lt;em&gt;digital transformation&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;That&#39;s usually the moment the trouble starts.&lt;/p&gt;
&lt;h2&gt;The old systems still running are usually the good ones&lt;/h2&gt;
&lt;p&gt;The genuinely terrible old systems mostly died years ago. The ones still alive today are the opposite. They&#39;re stable, and they&#39;re full of edge-case handling that nobody ever bothered to write down. They survived because they worked, not because they were pretty and not because they used a fashionable framework.&lt;/p&gt;
&lt;p&gt;A COBOL system that&#39;s moved millions of dollars without a bad night in fifteen years isn&#39;t bad software. It just has the wrong haircut.&lt;/p&gt;
&lt;h2&gt;The rewrite question is the wrong question&lt;/h2&gt;
&lt;p&gt;The first question in the room is almost always &lt;em&gt;&amp;quot;how do we rewrite this?&amp;quot;&lt;/em&gt;. That&#39;s already a wrong turn. The question worth asking is &lt;em&gt;&amp;quot;what are we actually trying to fix?&amp;quot;&lt;/em&gt;, and those are not the same thing.&lt;/p&gt;
&lt;p&gt;Sometimes the real pain is missing documentation. Sometimes it&#39;s onboarding that takes six months, or a system with no API to integrate against, or reporting that can&#39;t answer a simple question. None of that requires touching the core business logic. But rewriting feels good, especially to a team that didn&#39;t build the original and doesn&#39;t carry any of its scars.&lt;/p&gt;
&lt;h2&gt;&amp;quot;Nobody understands the old system&amp;quot;&lt;/h2&gt;
&lt;p&gt;I hear this one constantly. &lt;em&gt;&amp;quot;The old code is impossible to understand.&amp;quot;&lt;/em&gt; And yet payroll still runs, invoices still go out, the warehouse still syncs, and customers still get billed the right amount. Somebody understood it well enough for the business to survive two decades.&lt;/p&gt;
&lt;p&gt;The real problem usually isn&#39;t the code. It&#39;s that the people who knew it retired, the documentation was neglected, and maintenance got starved for years because nothing was ever visibly on fire. That&#39;s an organizational failure wearing a technical costume. Rewriting the software doesn&#39;t fix the culture that let the knowledge walk out the door.&lt;/p&gt;
&lt;h2&gt;Rewrites are very good at deleting invisible logic&lt;/h2&gt;
&lt;p&gt;This is where it gets expensive. Old enterprise systems accumulate thousands of small business rules over time: a special price for one customer, an accounting exception for one jurisdiction, a regulatory quirk, a workaround discovered during some incident in 2011. Most of it lives nowhere. Not in the docs, not in a ticket, not in a spec. It lives in the running system.&lt;/p&gt;
&lt;p&gt;When a team rewrites from scratch, they tend to rediscover those rules one production outage at a time, with the customer watching.&lt;/p&gt;
&lt;h2&gt;&amp;quot;Modern&amp;quot; and &amp;quot;simpler&amp;quot; are not the same word&lt;/h2&gt;
&lt;p&gt;There&#39;s a specific move I&#39;ve watched play out more than once. Replace one stable monolith with a dozen microservices, a service mesh, an event bus, distributed tracing, four cloud environments and a pile of YAML, all to reproduce a process that used to run on a single box in a basement.&lt;/p&gt;
&lt;p&gt;You haven&#39;t simplified anything. You&#39;ve turned a maintenance job into a permanent platform team.&lt;/p&gt;
&lt;h2&gt;The strangler pattern is boring for a reason&lt;/h2&gt;
&lt;p&gt;The modernizations that actually work tend to avoid the big-bang rewrite entirely. You wrap the stable system in an API, replace one piece at a time, keep the proven logic running while you go, and shrink the risk on every step instead of betting the company on a cutover weekend.&lt;/p&gt;
&lt;p&gt;It doesn&#39;t make a thrilling conference talk. It just keeps the business alive. You don&#39;t demolish the whole building because one floor needs rewiring.&lt;/p&gt;
&lt;h2&gt;The hard part was never the technology&lt;/h2&gt;
&lt;p&gt;Most of these projects don&#39;t fail because COBOL is scary. They fail on unclear ownership, optimistic timelines, missing domain knowledge, and management treating a migration as a way to reset organizational problems through software. That almost never works.&lt;/p&gt;
&lt;p&gt;The technology is usually the most cooperative thing in the room.&lt;/p&gt;
&lt;h2&gt;Greenfield is fun. Businesses don&#39;t live there.&lt;/h2&gt;
&lt;p&gt;I get the appeal. Greenfield work is clean architecture, new tools, and nobody else&#39;s old decisions to inherit. But real companies come with history, integrations, regulators, customer expectations, and twenty years of accumulated logic.&lt;/p&gt;
&lt;p&gt;Software engineering isn&#39;t architecture cosplay. The job isn&#39;t to build the prettiest system. It&#39;s to keep the business running while you make it better.&lt;/p&gt;
&lt;h2&gt;Sometimes the right move is almost nothing&lt;/h2&gt;
&lt;p&gt;This one makes people uncomfortable. Occasionally the best modernization plan is to fix the backups, add real monitoring, write down how the thing actually works, put the source under version control, and upgrade the infrastructure carefully, without touching the core at all.&lt;/p&gt;
&lt;p&gt;Not every old system needs reinventing. Some of them just need the maintenance they were denied for ten years.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;There&#39;s a real difference between software that looks old and software that&#39;s failing. A surprising amount of &amp;quot;modernization&amp;quot; is aesthetic discomfort dressed up as technical necessity.&lt;/p&gt;
&lt;p&gt;Old systems can genuinely become dangerous. Unsupported hardware, real security holes, vendor lock-in, nobody left to hire. Those are worth acting on. But a full rewrite is usually the most expensive possible answer to any of them.&lt;/p&gt;
&lt;p&gt;The most senior decision you can make is to notice that the old system isn&#39;t the enemy. The missing understanding around it is.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;I help teams maintain, debug, and modernize OpenVMS and other legacy systems without the risky rewrite: COBOL, RMS, Rdb, and integration with modern infrastructure. If you&#39;re sitting on something critical that everyone&#39;s afraid to touch, &lt;a href=&quot;https://ddz.dev/openvms/&quot;&gt;that&#39;s the conversation worth having&lt;/a&gt;.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>NAT Gateway is eating your AWS bill</title>
        <link href="https://ddz.dev/blog/aws-nat-gateway-bill/"/>
        <id>https://ddz.dev/blog/aws-nat-gateway-bill/</id>
        <updated>2026-05-10T00:00:00.000Z</updated>
        <summary>NAT Gateway is usually the biggest line item on a small AWS bill. Here&#39;s why bootstrapped founders end up paying for enterprise architecture long before the product needs any of it.</summary>
        <content type="html">&lt;p&gt;NAT Gateway costs $0.045 an hour just for existing. That&#39;s about $33 a month per gateway before a single byte passes through it, plus another $0.045 per gigabyte for every byte that does. Two of them across two availability zones, like every reference architecture suggests, and you&#39;re already at $66 a month before your app does anything.&lt;/p&gt;
&lt;p&gt;This is the part of AWS that quietly eats bootstrapped startups. You read a few &amp;quot;production-ready&amp;quot; tutorials, you copy a sensible-looking diagram, and the next thing you have is private subnets, ECS on Fargate, RDS in two availability zones, an Application Load Balancer, NAT Gateways, and CloudWatch shoveling logs at every layer. It feels solid. It feels like the grown-up version of what you used to do on a $5 VPS.&lt;/p&gt;
&lt;p&gt;Then the first real bill lands. Twelve users, almost no traffic, barely any revenue, and AWS already wants several hundred dollars a month for the privilege.&lt;/p&gt;
&lt;p&gt;Most of that bill isn&#39;t your app. It&#39;s the plumbing around your app, and NAT Gateway is the biggest piece of it.&lt;/p&gt;
&lt;h2&gt;The architecture everyone copies&lt;/h2&gt;
&lt;p&gt;The pattern the tutorials hand you usually looks like this: public subnets for the load balancer, private subnets for the application containers, private subnets for the database, outbound internet routed through a NAT Gateway.&lt;/p&gt;
&lt;p&gt;From a security angle it&#39;s fine. The application servers aren&#39;t directly exposed. The database is isolated. The diagram looks clean enough to put in a deck.&lt;/p&gt;
&lt;p&gt;The issue is who that pattern was actually written for. AWS reference architectures are aimed at companies with compliance teams, funded startups with platform engineers, and internal systems where six-figure infra bills don&#39;t show up in anyone&#39;s personal life. They aren&#39;t written for one person trying to ship version one without burning rent money.&lt;/p&gt;
&lt;p&gt;Copying the enterprise pattern at the prototype stage isn&#39;t being responsible. It&#39;s just expensive.&lt;/p&gt;
&lt;h2&gt;Private subnets aren&#39;t free internet&lt;/h2&gt;
&lt;p&gt;Here&#39;s the part the tutorials don&#39;t dwell on: a container in a private subnet can&#39;t reach the internet on its own. Every time it pulls a Docker image, hits Stripe, calls SendGrid, fetches an OAuth token, ships logs to a third party, or talks to S3 over the public endpoint, that traffic has to go somewhere. AWS routes it through the NAT Gateway, and now you&#39;re paying $0.045 a gigabyte for the privilege of letting your container reach Stripe.&lt;/p&gt;
&lt;p&gt;Most of this is invisible while you&#39;re building. Your app might be quiet, but the infrastructure around it is chatty all day. Logs, metrics, package updates, container pulls, health checks, background workers, third-party API calls, all of it flowing through the same expensive pipe.&lt;/p&gt;
&lt;h2&gt;Why this hits bootstrapped people the hardest&lt;/h2&gt;
&lt;p&gt;NAT Gateway isn&#39;t bad. The problem is what it does to the early-stage P&amp;amp;L.&lt;/p&gt;
&lt;p&gt;It introduces a fixed monthly infrastructure tax that exists before the product earns a single dollar. If you raised funding, that&#39;s annoying but invisible. If you&#39;re a solo founder paying out of personal savings while validating an idea, it&#39;s the difference between &lt;em&gt;&amp;quot;I can run this for a year&amp;quot;&lt;/em&gt; and &lt;em&gt;&amp;quot;I have to pull the plug in three months.&amp;quot;&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;The shape of the bill ends up bizarre. Tiny database, minimal compute, almost no users, and yet the AWS invoice looks like you&#39;re running a medium-sized SaaS. I&#39;ve seen setups where NAT Gateway alone cost more than the entire application compute it was supposedly serving.&lt;/p&gt;
&lt;p&gt;That&#39;s a strange place to be when you&#39;re still trying to find out whether anyone wants the product.&lt;/p&gt;
&lt;h2&gt;The cross-AZ surprise&lt;/h2&gt;
&lt;p&gt;This is the part that catches people who thought they were being careful.&lt;/p&gt;
&lt;p&gt;Common shape: ECS tasks in one AZ, NAT Gateway in another, usually because somebody set up the VPC with a single NAT Gateway &amp;quot;for cost reasons&amp;quot; and didn&#39;t realize the tasks would land elsewhere. Now every outbound request from those tasks pays cross-AZ data transfer on top of NAT processing.&lt;/p&gt;
&lt;p&gt;AWS doesn&#39;t pop up a banner saying &lt;em&gt;&amp;quot;congratulations, your tiny side project is now paying inter-AZ networking fees.&amp;quot;&lt;/em&gt; You discover it later, staring at Cost Explorer and trying to work out why &amp;quot;outbound HTTPS to api.stripe.com&amp;quot; turned into a four-figure annual line item.&lt;/p&gt;
&lt;h2&gt;Most early products don&#39;t need private subnets yet&lt;/h2&gt;
&lt;p&gt;This is the part that makes people uncomfortable.&lt;/p&gt;
&lt;p&gt;For a lot of early-stage products, the right answer is genuinely a single EC2 instance. Or App Runner. Or a small ECS service in a public subnet with a security group doing the actual exposure control. Or a Hetzner box that costs less per month than the NAT Gateway alone. The internet will not collapse because your MVP isn&#39;t wired up like a Fortune 500 banking platform.&lt;/p&gt;
&lt;p&gt;What early infrastructure should optimize for is simplicity, cost, and how fast you can change things. Not how impressive the diagram looks on LinkedIn.&lt;/p&gt;
&lt;p&gt;There are also middle paths nobody talks about. VPC endpoints for S3 and DynamoDB skip NAT entirely for that traffic, often for free. Interface endpoints handle a lot of AWS service calls. IPv6 egress-only gateways give you outbound internet for IPv6 without NAT pricing. Each of these is a small piece of work and a real line off your bill.&lt;/p&gt;
&lt;h2&gt;AWS isn&#39;t the villain&lt;/h2&gt;
&lt;p&gt;NAT Gateway exists for good reasons. At scale it simplifies networking, centralizes outbound access, and gives security teams a chokepoint they can audit. In a mature business the cost is rounding noise.&lt;/p&gt;
&lt;p&gt;The mismatch is what kills you. Applying enterprise architecture to a product that&#39;s still searching for product-market fit is the same mistake as hiring a CFO before you have revenue. It looks like seriousness. It&#39;s actually just overhead.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;There&#39;s a difference between architecture that scales technically and architecture that scales economically. Most early-stage AWS bills are paying for the first one before the business needs either.&lt;/p&gt;
&lt;p&gt;Good engineering at the prototype stage isn&#39;t picking the most sophisticated infrastructure on offer. It&#39;s picking the smallest thing that does the job, and only adding the next layer once the business actually asks for it. Sometimes the most senior decision in the room is admitting you don&#39;t need the fancy setup yet.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;If your AWS bill is way bigger than your product, &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;send me the details&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>WordPress fleets on AWS are rotting from the database up</title>
        <link href="https://ddz.dev/blog/wordpress-fleets-on-aws/"/>
        <id>https://ddz.dev/blog/wordpress-fleets-on-aws/</id>
        <updated>2026-04-27T00:00:00.000Z</updated>
        <summary>Aging WordPress fleets on AWS get blamed on PHP, plugins, or traffic. The real rot is usually in the database layer, and Cloudflare hides it until the bill arrives.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Diagnosis&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;The database is doing more than you think, and is usually undersized for it&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;Cloudflare is hiding how sick the origin really is&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;The AWS bill is paying for years of &quot;just in case&quot;, not for performance&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;None of this is a plugin problem.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;Every dashboard was green and the site didn&#39;t load.&lt;/p&gt;
&lt;p&gt;Web server CPU: bored. Memory: fine. Apache happily handling whatever Cloudflare forwarded its way. Pages took eight or ten seconds to start rendering, then stalled, then maybe came back. Like Wi-Fi at a busy coffee shop.&lt;/p&gt;
&lt;p&gt;The database lived on its own RDS instance, and that was the part that confused everyone. It had been split off the EC2 box two years earlier specifically &lt;em&gt;for reliability&lt;/em&gt;. MySQL on the same EC2 box as PHP kept saturating things during traffic spikes, so the operator did the responsible thing: peeled it off, put it on a gp2-backed db.t3 RDS, slept better at night. Problem solved.&lt;/p&gt;
&lt;p&gt;Two years later, that exact instance was the one that went silent. The RDS storage burst balance had drained to zero somewhere around 9am, and from then on every query was waiting on disk IOPS the instance no longer had. Web server: bored. RDS: gasping. Dashboards: green, apart from one graph nobody was watching.&lt;/p&gt;
&lt;p&gt;The fix from two years ago had quietly become the bug. That sentence is most of what&#39;s wrong with WordPress fleets on AWS, summarized.&lt;/p&gt;
&lt;h2&gt;How these fleets accumulate&lt;/h2&gt;
&lt;p&gt;An aging WordPress fleet on AWS is almost never something somebody designed. It&#39;s something that happened.&lt;/p&gt;
&lt;p&gt;You start with one site on a small EC2. WordPress is light, one box handles it fine. Then you add a second site, then a third. The agency takes on a client whose old site &lt;em&gt;just needs to stay online&lt;/em&gt; for another year. A WooCommerce install lands. A dev environment becomes a staging environment becomes a &amp;quot;we don&#39;t really know what this one does anymore.&amp;quot; Different developers install different caching plugins. PHP versions drift between sites that share a box. Cron jobs pile up. Backups multiply, then overlap, then nobody&#39;s sure which ones still work. One client launches a campaign and melts shared resources for everyone else for forty minutes on a Tuesday morning.&lt;/p&gt;
&lt;p&gt;None of these decisions was wrong on the day it was made. Together, they build a system nobody can confidently change.&lt;/p&gt;
&lt;h2&gt;The database is doing more than you think&lt;/h2&gt;
&lt;p&gt;Most WordPress performance complaints are really database complaints in a costume.&lt;/p&gt;
&lt;p&gt;Pages look slow because PHP is sitting on its hands waiting for MySQL. Admin panels drag because half the popular plugins do queries that would embarrass a junior developer. Random timeouts show up because the storage burst balance on the RDS volume is gone and every query is now waiting on disk IOPS the instance simply doesn&#39;t have anymore.&lt;/p&gt;
&lt;p&gt;That last one is the trap. AWS sells burstable resources, things like db.t3 instances and gp2 volumes, that perform beautifully right up until the moment they don&#39;t. The credits last for hours of moderate use, then run out, then performance falls off a cliff at exactly the wrong time. To the customer it looks random. To the operator it looks like a haunted machine.&lt;/p&gt;
&lt;p&gt;Cheap infrastructure that collapses under normal usage isn&#39;t actually cheap. It&#39;s just billed differently.&lt;/p&gt;
&lt;h2&gt;Cloudflare hides a sick origin&lt;/h2&gt;
&lt;p&gt;Cloudflare in front of WordPress is genuinely useful. Edge caching, bot filtering, the SSL pipework, a cheap layer of armor against script kiddies. None of that is wrong.&lt;/p&gt;
&lt;p&gt;What it also does, and what nobody mentions in the marketing, is hide how sick the origin really is. From the outside the site looks mostly fine because cached pages render in milliseconds. The logged-in users, the checkout flow, the editors hitting wp-admin, every API call, every cache miss. All of those are talking to the dying origin and getting the dying-origin experience.&lt;/p&gt;
&lt;p&gt;Then traffic spikes, or the cache gets purged, or a campaign tag changes the URL pattern enough that nothing&#39;s in cache anymore, and the illusion drops. The thing was on fire the whole time. Cloudflare was just very good at the smoke screen.&lt;/p&gt;
&lt;h2&gt;The AWS bill is paying for caution, not performance&lt;/h2&gt;
&lt;p&gt;The bill on these setups grows in places that have very little to do with how the sites actually perform.&lt;/p&gt;
&lt;p&gt;EC2 instances stay one or two sizes too big because nobody trusts downsizing without a load test that nobody has time to run. RDS instances carry years of inefficient queries the team has learned to live with. gp2 volumes sit there bursting and then not-bursting; nobody upgrades to gp3 because nobody knows the calculator off the top of their head. Snapshots accumulate at one per day per instance and nobody has audited the retention policy since 2021. There&#39;s an idle Application Load Balancer that used to front something. There&#39;s a NAT Gateway moving the tiniest amount of traffic at the steepest possible per-GB rate. Two of the environments are duplicates of each other &amp;quot;just in case.&amp;quot; A third-party APM is collecting metrics from a service that was decommissioned eighteen months ago.&lt;/p&gt;
&lt;p&gt;None of these line items will hurt you on their own. Together they&#39;re often a third of the bill.&lt;/p&gt;
&lt;h2&gt;You&#39;re not managing traffic, you&#39;re managing entropy&lt;/h2&gt;
&lt;p&gt;People think a fleet of fifty low-traffic WordPress sites is easier than one busy app. It isn&#39;t.&lt;/p&gt;
&lt;p&gt;A busy app is one codebase, one deploy pipeline, one PHP version, one caching strategy, one set of cron jobs. You can know that app. Fifty WordPress sites are fifty plugin lists, fifty theme variants, fifty backup schedules, fifty sets of email quirks, fifty sets of client-specific surprises that nobody documented. A handful of them are running PHP versions you&#39;d rather not name out loud. Two of them are quietly out of disk. One has been compromised for a month and is mining something.&lt;/p&gt;
&lt;p&gt;You&#39;re not managing traffic. You&#39;re managing entropy.&lt;/p&gt;
&lt;p&gt;That&#39;s why agencies feel exhausted even when no single site is &amp;quot;big.&amp;quot; The exhaustion isn&#39;t proportional to traffic. It&#39;s proportional to the number of independent things that can go wrong overnight without warning.&lt;/p&gt;
&lt;h2&gt;What I&#39;d actually do if I inherited one&lt;/h2&gt;
&lt;p&gt;If somebody handed me a tangled WordPress fleet on AWS tomorrow, I wouldn&#39;t start with Kubernetes, six new services, or any of the architecture cosplay that gets pitched at this stage. I&#39;d start by knowing what&#39;s there.&lt;/p&gt;
&lt;p&gt;First, an honest inventory. Which sites still make money? Which ones are abandoned? Which ones are legacy liabilities that nobody owns and nobody wants to admit they don&#39;t own? Most operators, when pressed, can&#39;t actually answer this. That&#39;s the real starting point.&lt;/p&gt;
&lt;p&gt;Then, separation. The site that pays the rent shouldn&#39;t be sharing a database with a brochure site from 2018 that nobody&#39;s logged into in three years. Carve the important workloads off, even if it means duplicating a bit of infrastructure. Noisy neighbors are cheap to fix once you know who they are.&lt;/p&gt;
&lt;p&gt;Then the database. Right-size the instances, move gp2 volumes to gp3, kill the queries that have been embarrassing themselves for years, fix the indexes that should have been there from the start. This usually moves the needle more than anything you can do to PHP.&lt;/p&gt;
&lt;p&gt;Then standardize the runtime. One PHP version, one deploy pattern, one caching approach, one monitoring stack. Mixed environments create the kind of friction that quietly burns a senior engineer&#39;s afternoon every other day.&lt;/p&gt;
&lt;p&gt;Then question every component that exists &amp;quot;because it sounded smart in 2022.&amp;quot; If the load balancer is fronting one instance, it isn&#39;t load balancing. If the NAT Gateway exists for one outbound API call a day, it&#39;s a $30/month rental for a single phone call.&lt;/p&gt;
&lt;p&gt;Finally, decide what should leave WordPress entirely. Some sites should become static. Some should be rebuilt against something more boring. Some should be quietly retired and replaced with a 301 redirect.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;WordPress isn&#39;t the villain in any of these stories. AWS isn&#39;t either. The villain is the slow accumulation of decisions that were each individually reasonable, made by people who weren&#39;t there for the previous ones, on top of infrastructure that will quietly let you build whatever you want.&lt;/p&gt;
&lt;p&gt;At some point the cheapest optimization isn&#39;t another instance upgrade. It&#39;s stepping back, looking at what you actually have, and putting it back into a shape somebody can understand.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Inherited a tangled WordPress fleet on AWS?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Send me the details&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Just want someone to take care of your WordPress hosting?&lt;/strong&gt; &lt;a href=&quot;https://biro.works/services#wordpress&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Biro.works&lt;/a&gt; does a great job of exactly that.&lt;/p&gt;
&lt;!--
  Buy panel for the WordPress AWS Rescue Package (commented out until Stripe is live).
  When ready to ship: delete the contact CTA above, uncomment everything below this line,
  and replace the test Payment Link URL with the live one. Setup checklist is in
  docs/analytics-events.md → &quot;5. Stripe: Payment Link + webhook setup&quot;.

&lt;section data-buy-panel data-sku=&quot;wp-rescue&quot; class=&quot;my-12 bg-purple-900 text-white rounded-lg p-8 shadow-lg&quot;&gt;
&lt;div class=&quot;flex items-center mb-4&quot;&gt;&lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M3 7h18M3 12h18M3 17h12&quot;/&gt;&lt;/svg&gt; &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Offer&lt;/span&gt;&lt;/div&gt;
&lt;h2 class=&quot;text-2xl md:text-3xl font-bold mb-3&quot; style=&quot;font-family: &#39;Instrument Serif&#39;, serif&quot;&gt;WordPress AWS Rescue Package&lt;/h2&gt;
&lt;p class=&quot;text-lg mb-6&quot;&gt;If any of that sounded familiar, here&#39;s a way out.&lt;/p&gt;
&lt;ul class=&quot;space-y-2 mb-6&quot;&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;90-minute discovery call.&lt;/strong&gt; Your account, your fleet, your bills, the things that scare you.&lt;/span&gt;&lt;/li&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;Architecture review.&lt;/strong&gt; What you have, what&#39;s load-bearing, what&#39;s quietly broken.&lt;/span&gt;&lt;/li&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;Cost review.&lt;/strong&gt; Line by line, with the wins ranked by effort.&lt;/span&gt;&lt;/li&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;Performance bottlenecks.&lt;/strong&gt; RDS, storage, caching, queries, the actual ones.&lt;/span&gt;&lt;/li&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;Security red flags.&lt;/strong&gt; Exposed surfaces, unpatched plugins, IAM nonsense.&lt;/span&gt;&lt;/li&gt;
&lt;li class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 flex-shrink-0&quot;&gt;›&lt;/span&gt;&lt;span&gt;&lt;strong&gt;30-day action plan.&lt;/strong&gt; Written, prioritized, executable by your team.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div class=&quot;flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 pt-6 border-t border-purple-800&quot;&gt;
&lt;div&gt;&lt;div class=&quot;text-3xl font-bold&quot;&gt;$1,900&lt;/div&gt;&lt;div class=&quot;text-sm text-pink-200&quot;&gt;One-time, USD. No subscription.&lt;/div&gt;&lt;/div&gt;
&lt;a href=&quot;https://buy.stripe.com/test_REPLACE_WITH_LIVE_PAYMENT_LINK&quot; data-sku=&quot;wp-rescue&quot; data-price=&quot;1900&quot; class=&quot;inline-block px-6 py-3 bg-pink-500 text-white font-bold rounded hover:bg-pink-400 transition-colors text-center&quot;&gt;Buy the rescue package →&lt;/a&gt;
&lt;/div&gt;
&lt;p class=&quot;text-xs text-pink-200 mt-4&quot;&gt;Secure checkout on Stripe. By purchasing you agree to the &lt;a href=&quot;https://ddz.dev/terms/&quot; class=&quot;text-white underline hover:text-pink-300&quot;&gt;Terms&lt;/a&gt; and &lt;a href=&quot;https://ddz.dev/refunds/&quot; class=&quot;text-white underline hover:text-pink-300&quot;&gt;Refund Policy&lt;/a&gt;.&lt;/p&gt;
&lt;/section&gt;

&lt;script type=&quot;application/ld+json&quot;&gt;
{&quot;@context&quot;:&quot;https://schema.org&quot;,&quot;@type&quot;:&quot;Product&quot;,&quot;name&quot;:&quot;WordPress AWS Rescue Package&quot;,&quot;description&quot;:&quot;A 90-minute discovery call plus architecture, cost, performance, and security review of an AWS-hosted WordPress fleet, delivered as a written 30-day action plan.&quot;,&quot;brand&quot;:{&quot;@type&quot;:&quot;Organization&quot;,&quot;name&quot;:&quot;ddz.dev LLC&quot;},&quot;offers&quot;:{&quot;@type&quot;:&quot;Offer&quot;,&quot;price&quot;:&quot;1900&quot;,&quot;priceCurrency&quot;:&quot;USD&quot;,&quot;availability&quot;:&quot;https://schema.org/InStock&quot;,&quot;url&quot;:&quot;https://ddz.dev/blog/wordpress-fleets-on-aws/&quot;,&quot;seller&quot;:{&quot;@type&quot;:&quot;Organization&quot;,&quot;name&quot;:&quot;ddz.dev LLC&quot;}}}
&lt;/script&gt;

--&gt;
</content>
    </entry>
    <entry>
        <title>The one setting that stops Microsoft Authenticator spam</title>
        <link href="https://ddz.dev/blog/microsoft-authenticator-spam-notifications/"/>
        <id>https://ddz.dev/blog/microsoft-authenticator-spam-notifications/</id>
        <updated>2026-04-15T00:00:00.000Z</updated>
        <summary>Why changing your password doesn&#39;t stop Microsoft Authenticator spam notifications, and the one setting change that does. Target the real attack vector.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Fix (TL;DR)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;Add a new alias email to your Microsoft account&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;Make it primary login&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;Remove old login email&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;Done.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;For months I was getting hammered with Microsoft Authenticator login prompts. Not one or two, but easily ten or more a day, always from places like Germany or the Netherlands, always labeled &amp;quot;Windows,&amp;quot; and always at random times. At first it felt a bit concerning, then it just became incredibly annoying because I had to keep tapping Deny over and over again like some kind of human firewall.&lt;/p&gt;
&lt;h2&gt;My first assumption: compromised password&lt;/h2&gt;
&lt;p&gt;Naturally, I assumed the obvious: someone must have my password. So I changed it to something strong and completely new, logged out of all sessions, checked account activity, and generally did everything you&#39;re supposed to do when you think your account might be compromised. Everything looked clean, but the prompts kept coming as if nothing had changed.&lt;/p&gt;
&lt;h2&gt;Why password resets don&#39;t stop this attack&lt;/h2&gt;
&lt;p&gt;That&#39;s when it became clear that this wasn&#39;t a typical &amp;quot;someone got your password&amp;quot; situation. They don&#39;t actually need your password for this. All they need is your email address, and if that email is public (which mine is), they can keep triggering login attempts that push notifications to your Authenticator app. The whole idea is simple: spam enough requests and hope you eventually approve one by mistake, whether out of distraction or frustration.&lt;/p&gt;
&lt;h2&gt;Things I tried that didn&#39;t work&lt;/h2&gt;
&lt;p&gt;I went through all the usual fixes people suggest. I verified passwordless login was off. I removed and re-added Authenticator. I logged out of every session again just to be sure. I checked the sign-in logs, which turned out to be mostly useless because they only showed my successful logins and none of the garbage attempts coming from abroad. Nothing made a difference. The system was technically secure, but I was still being spammed constantly.&lt;/p&gt;
&lt;h2&gt;The fix that actually worked&lt;/h2&gt;
&lt;p&gt;What finally fixed it had nothing to do with passwords or Authenticator itself. I went into account settings, added a new email alias that nobody knows, made it the primary login, and removed (or at least disabled) my old email as a sign-in option.&lt;/p&gt;
&lt;p&gt;The effect was immediate. The prompts stopped completely. No gradual decrease, no delay, just silence.&lt;/p&gt;
&lt;h2&gt;Why this works (and why the fix is so hidden)&lt;/h2&gt;
&lt;p&gt;The reason is pretty simple in hindsight. All those attempts were targeting my old, public email address. Once that email stopped being a valid login identifier, the attacker was effectively knocking on a door that no longer existed. The authentication process never even started, so there was nothing left to trigger on my phone.&lt;/p&gt;
&lt;p&gt;Looking back, everything I tried before was focused on strengthening authentication, but the real problem was the entry point itself. As long as your public email is a valid login, someone can keep poking at it and triggering prompts, regardless of how strong your password is or whether passwordless is enabled.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;So if you&#39;re dealing with the same thing, don&#39;t waste time cycling through password changes and app resets. The only thing that actually stopped it for me was changing the login alias to something private and removing the old one from sign-in. It&#39;s a small change, but it completely cuts off the attack vector.&lt;/p&gt;
&lt;p&gt;It&#39;s also a bit frustrating that the system works this way, because instead of blocking the attacker upstream, it basically offloads the problem onto you and expects you to keep denying requests. But once you understand what&#39;s really being targeted, the fix becomes obvious. And thankfully, it works instantly.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Running into weird authentication or security issues?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Send me your problem&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Your server is running two PHP versions (and one of them is lying)</title>
        <link href="https://ddz.dev/blog/php-cli-vs-apache-mismatch/"/>
        <id>https://ddz.dev/blog/php-cli-vs-apache-mismatch/</id>
        <updated>2026-03-24T00:00:00.000Z</updated>
        <summary>PHP works in the terminal but fails in the browser with a weird missing-constant error? CLI and Apache are probably running different PHP versions. Here&#39;s how to check and fix the mismatch.</summary>
        <content type="html">&lt;aside class=&quot;my-8 bg-purple-900 text-white rounded-lg p-6 shadow-lg&quot;&gt;
    &lt;div class=&quot;flex items-center mb-4&quot;&gt;
        &lt;svg class=&quot;w-5 h-5 text-pink-400 mr-2 flex-shrink-0&quot; fill=&quot;none&quot; viewBox=&quot;0 0 24 24&quot; stroke=&quot;currentColor&quot; stroke-width=&quot;2.5&quot;&gt;&lt;path stroke-linecap=&quot;round&quot; stroke-linejoin=&quot;round&quot; d=&quot;M13 10V3L4 14h7v7l9-11h-7z&quot;/&gt;&lt;/svg&gt;
        &lt;span class=&quot;text-pink-400 text-xs font-bold uppercase tracking-wider&quot;&gt;The Fix (TL;DR)&lt;/span&gt;
    &lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;1.&lt;/span&gt;&lt;span&gt;Run &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;php -v&lt;/code&gt; to see what CLI is using&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;2.&lt;/span&gt;&lt;span&gt;Open &lt;code class=&quot;bg-purple-950 px-1.5 py-0.5 rounded text-pink-200&quot;&gt;phpinfo()&lt;/code&gt; in the browser to see what the web server is using&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start mb-2&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;3.&lt;/span&gt;&lt;span&gt;If the versions differ, align mod_php or the FPM socket path&lt;/span&gt;&lt;/div&gt;
    &lt;div class=&quot;flex items-start&quot;&gt;&lt;span class=&quot;text-pink-400 font-bold mr-3 w-6 flex-shrink-0&quot;&gt;✓&lt;/span&gt;&lt;span class=&quot;font-bold&quot;&gt;Restart Apache. Done.&lt;/span&gt;&lt;/div&gt;
&lt;/aside&gt;
&lt;p&gt;I ran into a classic PHP mess the other day. A Symfony app was behaving perfectly fine from the command line (no warnings, no drama, nothing flagged as broken) and then the moment I loaded it in the browser, it blew up with this:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;Undefined constant &quot;XML_PI_NODE&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&#39;s the kind of error that sends you chasing ghosts: something deep in the app, a flaky extension, a weird dependency mismatch buried in Composer. What it does not immediately scream is &lt;em&gt;&amp;quot;your server is running two different PHP versions depending on how you access it.&amp;quot;&lt;/em&gt; But that was exactly what was happening. CLI was on one PHP version, Apache was on another, and even though both were running on the same box against the same files, the two paths were serving code through completely different runtimes. From the terminal everything looked healthy. From the browser, things started breaking in confusing ways.&lt;/p&gt;
&lt;h2&gt;Why &amp;quot;PHP version&amp;quot; isn&#39;t really one thing&lt;/h2&gt;
&lt;p&gt;On Linux, &amp;quot;PHP version&amp;quot; is rarely a single value. The CLI binary can be on one version, the Apache module on another, and PHP-FPM on a third, all sitting quietly side by side on the same machine. Upgrade one of them and forget about the rest, and the box keeps limping along fine until something in the code hits behavior that differs between versions. That&#39;s usually when the weird errors start, and the most maddening part is that they don&#39;t show up consistently. The app isn&#39;t really &amp;quot;broken.&amp;quot; It&#39;s only broken down one execution path.&lt;/p&gt;
&lt;h2&gt;The first thing I always check now&lt;/h2&gt;
&lt;p&gt;Any time PHP works in the terminal but not in the browser, I start with the same two commands. The first tells me what CLI is using:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;php &lt;span class=&quot;token parameter variable&quot;&gt;-v&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s half the picture. The other half is what the web server is actually running, and the quickest way to see it is still the oldest trick in the book: drop a temporary &lt;code&gt;phpinfo()&lt;/code&gt; file into the web root:&lt;/p&gt;
&lt;pre class=&quot;language-php&quot;&gt;&lt;code class=&quot;language-php&quot;&gt;&lt;span class=&quot;token php language-php&quot;&gt;&lt;span class=&quot;token delimiter important&quot;&gt;&amp;lt;?php&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;phpinfo&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;span class=&quot;token punctuation&quot;&gt;;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Open it in the browser and compare the version it reports with what &lt;code&gt;php -v&lt;/code&gt; gave you. If the two numbers don&#39;t match, there&#39;s nothing else to investigate yet. You&#39;ve already found the problem, and the rest of the debugging is just pinning down which part of the stack is out of sync.&lt;/p&gt;
&lt;h2&gt;If you&#39;re running mod_php&lt;/h2&gt;
&lt;p&gt;If Apache is loading PHP directly through mod_php, check which module it currently has active:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;apache2ctl &lt;span class=&quot;token parameter variable&quot;&gt;-M&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; php&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can also look at what&#39;s installed on the system more broadly:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;dpkg &lt;span class=&quot;token parameter variable&quot;&gt;-l&lt;/span&gt; &lt;span class=&quot;token operator&quot;&gt;|&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;grep&lt;/span&gt; libapache2-mod-php&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Between those two outputs, it usually becomes obvious whether Apache is still wired up to an older PHP module that nobody remembered to disable after the upgrade.&lt;/p&gt;
&lt;h2&gt;If Apache is proxying to PHP-FPM&lt;/h2&gt;
&lt;p&gt;If you&#39;re on PHP-FPM instead, the Apache module isn&#39;t your problem at all. The socket path is. Look at your site config and find the line that points Apache at PHP-FPM. It&#39;s usually something like:&lt;/p&gt;
&lt;pre class=&quot;language-text&quot;&gt;&lt;code class=&quot;language-text&quot;&gt;/run/php/php8.x-fpm.sock&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That version number in the socket name really matters. It&#39;s very easy to install a newer FPM package thinking you&#39;ve upgraded PHP everywhere, while Apache is still happily talking to the old socket.&lt;/p&gt;
&lt;h2&gt;Why the error message wasted my time&lt;/h2&gt;
&lt;p&gt;The most irritating thing about this whole class of bug is that PHP never fails in a way that points at the real problem. It doesn&#39;t say, &lt;em&gt;&amp;quot;CLI is on 8.5, Apache is on 8.3, please sort your life out.&amp;quot;&lt;/em&gt; It just throws side effects at you: a missing constant here, a removed function there, some extension behaving differently, random incompatibilities that look for all the world like application bugs but are really environment bugs. And if you don&#39;t know to look at the environment first, you can burn hours debugging the wrong layer.&lt;/p&gt;
&lt;p&gt;That&#39;s exactly what happened to me here. It wasn&#39;t Symfony. It wasn&#39;t Composer. It wasn&#39;t the cache. It wasn&#39;t some mystical XML bug buried in a library. Apache was simply running a different PHP version than the terminal, and every other lead I chased was a dead end.&lt;/p&gt;
&lt;h2&gt;The actual fix&lt;/h2&gt;
&lt;p&gt;Once the mismatch is confirmed, the fix is almost embarrassingly simple: pick one PHP version and make every layer use it. If Apache is still loading an old mod_php, turn it off and turn the right one on:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;a2dismod php8.3&lt;br&gt;a2enmod php8.5&lt;br&gt;systemctl restart apache2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;If you&#39;re on PHP-FPM, install the version you actually want and update the site config so Apache points at the matching socket:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;apt&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;install&lt;/span&gt; php8.5-fpm&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then restart both services so the new pairing actually takes effect:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;systemctl restart php8.5-fpm&lt;br&gt;systemctl restart apache2&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After that, the weird error disappears and everything becomes boring again, which is exactly what you want from infrastructure.&lt;/p&gt;
&lt;h2&gt;How to keep this from happening again&lt;/h2&gt;
&lt;p&gt;The easiest way to avoid all of this is to be deliberate about your PHP setup. If you use PHP-FPM, use it everywhere and stop mixing it with mod_php. If you use mod_php, know which module is currently enabled and make sure it matches the CLI version. And if you keep multiple PHP versions installed on the same box (which is fine, and sometimes necessary), keep track of which service is pointing at which, because having multiple versions isn&#39;t really the problem. Forgetting that different parts of the stack can quietly drift apart from each other: that&#39;s the problem.&lt;/p&gt;
&lt;h2&gt;Takeaway&lt;/h2&gt;
&lt;p&gt;If PHP works from the terminal but not from the browser, don&#39;t dig into the app first. Start by comparing the PHP versions: check &lt;code&gt;php -v&lt;/code&gt;, check &lt;code&gt;phpinfo()&lt;/code&gt;, check your Apache module or your FPM socket path. There&#39;s a good chance the app is fine and the server is just the one lying to you.&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;&lt;strong&gt;Running into a weird PHP or Apache mismatch?&lt;/strong&gt; &lt;a href=&quot;https://ddz.dev/#contact&quot;&gt;Send me the details&lt;/a&gt; and I&#39;ll take a look.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Stop all running containers in Docker</title>
        <link href="https://ddz.dev/blog/docker-stop-all-containers/"/>
        <id>https://ddz.dev/blog/docker-stop-all-containers/</id>
        <updated>2020-10-03T00:00:00.000Z</updated>
        <summary>How to stop and kill all running Docker containers with a single command. Quick reference with docker kill and docker stop examples.</summary>
        <content type="html">&lt;p&gt;There&#39;s no &lt;code&gt;docker stop all&lt;/code&gt;. The command you actually want is:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop &lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-q&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&#39;s the whole trick. If you want to know why it works, what the fish shell version looks like, and when to reach for &lt;code&gt;kill&lt;/code&gt; instead, read on.&lt;/p&gt;
&lt;h2&gt;Why you&#39;d want this&lt;/h2&gt;
&lt;p&gt;Sometimes you start containers from your IDE and forget to stop them before switching projects. Or maybe you&#39;re done with your work (you lucky, you!) and don&#39;t want to waste memory or CPU cycles on services you aren&#39;t using. Either way, something is hogging the ports you need, eating your RAM and battery, and the IDE that started it all is long gone. Time for the terminal.&lt;/p&gt;
&lt;p&gt;Let&#39;s see what we&#39;re up against. What does &lt;code&gt;docker ps&lt;/code&gt; say?&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt;&lt;br&gt;CONTAINER ID        IMAGE               COMMAND                  CREATED             STATUS              PORTS                                            NAMES&lt;br&gt;9c07b6375e0e        my_project_tunnel   &lt;span class=&quot;token string&quot;&gt;&quot;/bin/sh -c &#39;rm -rf …&quot;&lt;/span&gt;   &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt; weeks ago         Up &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt; seconds        &lt;span class=&quot;token number&quot;&gt;0.0&lt;/span&gt;.0.0:8062-&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;8062&lt;/span&gt;/tcp                           my_project_tunnel_1&lt;br&gt;91b757beda05        my_project_web      &lt;span class=&quot;token string&quot;&gt;&quot;docker-php-entrypoi…&quot;&lt;/span&gt;   &lt;span class=&quot;token number&quot;&gt;4&lt;/span&gt; weeks ago         Up &lt;span class=&quot;token number&quot;&gt;2&lt;/span&gt; seconds        &lt;span class=&quot;token number&quot;&gt;0.0&lt;/span&gt;.0.0:8000-&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;80&lt;/span&gt;/tcp                             my_project_web_1&lt;br&gt;245f7e35f4f3        mysql:5.7           &lt;span class=&quot;token string&quot;&gt;&quot;docker-entrypoint.s…&quot;&lt;/span&gt;   &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt; months ago        Up &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt; seconds        &lt;span class=&quot;token number&quot;&gt;0.0&lt;/span&gt;.0.0:3306-&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;3306&lt;/span&gt;/tcp, &lt;span class=&quot;token number&quot;&gt;33060&lt;/span&gt;/tcp                my_project_db_1&lt;br&gt;77bc948bb148        mailhog/mailhog     &lt;span class=&quot;token string&quot;&gt;&quot;MailHog&quot;&lt;/span&gt;                &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt; months ago        Up &lt;span class=&quot;token number&quot;&gt;3&lt;/span&gt; seconds        &lt;span class=&quot;token number&quot;&gt;0.0&lt;/span&gt;.0.0:1025-&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;1025&lt;/span&gt;/tcp, &lt;span class=&quot;token number&quot;&gt;0.0&lt;/span&gt;.0.0:8025-&lt;span class=&quot;token operator&quot;&gt;&gt;&lt;/span&gt;&lt;span class=&quot;token number&quot;&gt;8025&lt;/span&gt;/tcp   my_project_mailhog_1&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Four containers. Docker&#39;s answer, out of the box, is four separate commands:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop 9c07b6375e0e&lt;br&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop 91b757beda05&lt;br&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop 245f7e35f4f3&lt;br&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop 77bc948bb148&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Quite a mouthful. You can shave it down by typing only the first few characters of each container ID, but the real fix is to make the shell do the work: run &lt;code&gt;docker ps&lt;/code&gt;, grab the container IDs, and feed them straight into &lt;code&gt;docker stop&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;Step 1: Get the running container IDs&lt;/h2&gt;
&lt;p&gt;Run &lt;code&gt;docker ps&lt;/code&gt; with the &lt;code&gt;-q&lt;/code&gt; flag and it returns only container IDs. Look!&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;$ &lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-q&lt;/span&gt;&lt;br&gt;&lt;br&gt;9c07b6375e0e&lt;br&gt;91b757beda05&lt;br&gt;245f7e35f4f3&lt;br&gt;77bc948bb148&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Many tutorials will tell you to add the &lt;code&gt;-a&lt;/code&gt; flag, making the full command &lt;code&gt;docker ps -aq&lt;/code&gt;. Here, &lt;code&gt;-a&lt;/code&gt; is redundant. According to the &lt;a href=&quot;https://docs.docker.com/engine/reference/commandline/ps/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;docker ps documentation&lt;/a&gt;, omitting &lt;code&gt;-a&lt;/code&gt; returns only running containers, which is exactly what we need. Stopped containers don&#39;t need stopping.&lt;/p&gt;
&lt;h2&gt;Step 2: Feed them to docker stop&lt;/h2&gt;
&lt;p&gt;Almost every shell lets you run a command in a subshell, capture its output, and use it as arguments for another command. This syntax works in &lt;strong&gt;bash&lt;/strong&gt; and &lt;strong&gt;zsh&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop &lt;span class=&quot;token variable&quot;&gt;&lt;span class=&quot;token variable&quot;&gt;$(&lt;/span&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; &lt;span class=&quot;token parameter variable&quot;&gt;-q&lt;/span&gt;&lt;span class=&quot;token variable&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And this works in &lt;strong&gt;fish&lt;/strong&gt;:&lt;/p&gt;
&lt;pre class=&quot;language-bash&quot;&gt;&lt;code class=&quot;language-bash&quot;&gt;&lt;span class=&quot;token function&quot;&gt;docker&lt;/span&gt; stop &lt;span class=&quot;token punctuation&quot;&gt;(&lt;/span&gt;docker &lt;span class=&quot;token function&quot;&gt;ps&lt;/span&gt; -q&lt;span class=&quot;token punctuation&quot;&gt;)&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can use &lt;code&gt;kill&lt;/code&gt; instead of &lt;code&gt;stop&lt;/code&gt; for a faster shutdown. It saves a few seconds, but not every service appreciates being killed; databases in particular would rather get a polite &lt;code&gt;stop&lt;/code&gt; and a chance to flush to disk. If the containers are throwaway dev services, kill away.&lt;/p&gt;
</content>
    </entry>
    <entry>
        <title>Why Docker?</title>
        <link href="https://ddz.dev/blog/why-docker/"/>
        <id>https://ddz.dev/blog/why-docker/</id>
        <updated>2020-10-01T00:00:00.000Z</updated>
        <summary>A practical introduction to Docker for web developers. Why containers matter, how they work, and when to use them.</summary>
        <content type="html">&lt;p&gt;Docker is an awesome tool for web app development. Back in the stone age (i.e. pre-2010), if you needed a specific version of PHP with a specific set of extensions and a specific version of MySQL, your dev environment had to be... well, pretty specific.&lt;/p&gt;
&lt;p&gt;Developers had to adjust their working environment to whatever the current project needed. That was a cumbersome task, so most of us settled for &amp;quot;close enough&amp;quot;: the PHP version would be somewhere in the ballpark of what was required, there might be a few extra extensions lying around, and most of the time it was fine. &lt;a href=&quot;https://www.kevinwanke.com/why-you-should-never-use-the-phrase-but-it-works-on-my-machine/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Except when it wasn&#39;t&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The next logical step was to create isolated environments used only for development. Some sort of virtual machine, so to speak. Actually, that&#39;s exactly what they&#39;re called. The process is very easy: download a Linux ISO, spin up a VM, install a fresh Linux on it, install Apache, PHP, and every extension and config you need, set up the network share and the virtual network interface, and within a week or two you&#39;re good to go. Rinse and repeat for every project you&#39;re working on. You might understand why this wasn&#39;t anyone&#39;s favorite option.&lt;/p&gt;
&lt;p&gt;Enter &lt;s&gt;Docker&lt;/s&gt; &lt;a href=&quot;https://www.vagrantup.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Vagrant&lt;/a&gt;. This tool was a great helper in the early 10s. Instead of messing with virtual machines by hand, you described what you wanted the VM to look like, and from then on simple up/down/destroy commands did the respective things to it. Vagrant was essential for replicating production on a development machine, but it still carried the overhead of running two (or more) entire operating systems at the same time. Not great for performance, battery life, or your carbon footprint.&lt;/p&gt;
&lt;p&gt;Enter &lt;a href=&quot;https://www.docker.com&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Docker&lt;/a&gt;. Depending on the host OS, it introduces practically no overhead while still letting you run anything on anything. You can give every service its own container and have them talk to each other over an internal network, expose any port or map it to any other port, and bring the entire app environment up or down &lt;a href=&quot;https://docs.docker.com/compose/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;using only one command&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;That&#39;s the whole pitch: production parity without the week of VM setup. Once you&#39;ve worked that way for a while, going back feels like hand-cranking a car.&lt;/p&gt;
</content>
    </entry>
</feed>
