<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
     xmlns:atom="http://www.w3.org/2005/Atom"
     xmlns:content="http://purl.org/rss/1.0/modules/content/"
     xmlns:media="http://search.yahoo.com/mrss/">
    <channel>
        <title><![CDATA[holas.pl]]></title>
        <link>https://holas.pl/</link>
        <description><![CDATA[Piece of web by Holas]]></description>
        <language>en</language>
        <atom:link href="https://holas.pl/feed/" rel="self" type="application/rss+xml"/>
                <lastBuildDate>Sat, 30 May 2026 00:00:00 +0000</lastBuildDate>
                        <item>
            <title><![CDATA[Multilanguage on a Static Site — Configuration Over Code]]></title>
            <link>https://holas.pl/blog/multilanguage-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/multilanguage-static-site/</guid>
                        <pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 9 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 8 covers responsive images and scheduled posts. Adding a second language to a Symfony site usually means duplicating controllers, hardcoding URL prefixes, and scattering locale checks everywhere. holas.pl takes a different approach: locale config lives in one YAML file, routes are …]]></description>
            <content:encoded><![CDATA[<p><em>This is part 9 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/responsive-images-scheduled-posts/">Part 8</a> covers responsive images and scheduled posts.</em></p>
<hr />
<p>Adding a second language to a Symfony site usually means duplicating controllers, hardcoding URL prefixes, and scattering locale checks everywhere. holas.pl takes a different approach: locale config lives in one YAML file, routes are generated from a custom PHP attribute, and translations are linked by the filesystem — not by explicit keys.</p>
<h2>The Problem with Hardcoded Locales<a id="the-problem-with-hardcoded-locales" href="#the-problem-with-hardcoded-locales" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first working version of holas.pl's multilanguage support had around 30 places with patterns like this:</p>
<pre><code class="language-php">#[Route('/blog/', name: 'blog_list_en')]
public function listEn(int $page = 1): Response
{
    return $this-&gt;renderList('en', $page);
}

#[Route('/pl/wpisy/', name: 'blog_list_pl')]
public function listPl(int $page = 1): Response
{
    return $this-&gt;renderList('pl', $page);
}
</code></pre>
<p>Every route had two methods — one per locale. The real controller logic lived in a private <code>render*()</code> method; the public methods were pure boilerplate that set the locale and delegated. Seven controllers × two locales = 28 methods doing nothing useful.</p>
<p>Adding a third language would mean adding 14 more methods, plus updating templates, the locale listener, and every place that checked <code>'pl' === $locale</code>. The code didn't scale.</p>
<h2>Single Source of Truth — <code>_site.yaml</code><a id="single-source-of-truth--siteyaml" href="#single-source-of-truth--siteyaml" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The fix starts with centralizing the locale list. Instead of spreading locale knowledge across PHP files, everything lives in <code>content/_site.yaml</code>:</p>
<pre><code class="language-yaml">site:
  locales:
    en:
      label: &quot;English&quot;
      og_locale: en_US
      date_format: &quot;M d, Y&quot;
    pl:
      label: &quot;Polski&quot;
      og_locale: pl_PL
      font_preload: fonts/inter-normal-latin-ext.woff2
      date_format: &quot;d.m.Y&quot;
</code></pre>
<p>Order matters: the first key is the default locale. Each locale carries its own metadata — <code>og_locale</code> for Open Graph tags, <code>date_format</code> for template rendering, <code>font_preload</code> for Latin Extended characters that only Polish needs.</p>
<p><code>SiteConfigService</code> reads this file once, caches it, and provides it to the entire application:</p>
<pre><code class="language-php">interface SiteConfigServiceInterface
{
    /** @return string[] Ordered locale codes, first = default */
    public function getLocales(): array;

    public function getDefaultLocale(): string;

    /** @return array&lt;string, mixed&gt; Config for a single locale */
    public function getLocaleConfig(string $locale): array;
}
</code></pre>
<p>Every controller, listener, and route loader injects this interface. No PHP code imports a locale list from <code>framework.yaml</code> or hardcodes <code>['en', 'pl']</code>.</p>
<h2>Custom Route Attribute — <code>#[LocalizedRoute]</code><a id="custom-route-attribute--localizedroute" href="#custom-route-attribute--localizedroute" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The key innovation is a custom PHP attribute that replaces duplicate route methods:</p>
<pre><code class="language-php">#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
final class LocalizedRoute
{
    public function __construct(
        public readonly string $name,
        public readonly string $path,
        public readonly array $requirements = [],
        public readonly array $methods = [],
        public readonly int $priority = 0,
    ) {
    }
}
</code></pre>
<p>The attribute defines a route for the <strong>default locale only</strong>. A custom <code>LocalizedRouteLoader</code> scans all controllers, finds <code>#[LocalizedRoute]</code> attributes, and generates <code>{name}_{locale}</code> routes for every configured locale:</p>
<pre><code class="language-php">#[LocalizedRoute('blog_list', path: '/blog/')]
#[LocalizedRoute('blog_list_paginated', path: '/blog/page/{page}/', requirements: ['page' =&gt; '\d+'])]
public function list(string $locale, int $page = 1): Response
{
    // one method handles all locales
}
</code></pre>
<p>This single method replaces the two <code>listEn()</code> / <code>listPl()</code> methods from before. The loader generates four routes from the two attributes: <code>blog_list_en</code>, <code>blog_list_pl</code>, <code>blog_list_paginated_en</code>, <code>blog_list_paginated_pl</code>.</p>
<p>The total across all controllers: 28 methods became 14. Every removed method was pure boilerplate.</p>
<h3>Route Resolution<a id="route-resolution" href="#route-resolution" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The loader needs to know that <code>/blog/</code> is the English path but <code>/pl/wpisy/</code> is the Polish one. A three-step resolution algorithm handles this:</p>
<pre><code class="language-php">private function resolvePath(
    string $name, string $defaultPath,
    string $locale, string $defaultLocale,
    array $overrides,
): string {
    if ($locale === $defaultLocale) {
        return $defaultPath;                          // EN: /blog/
    }

    if (isset($overrides[$name][$locale])) {
        return '/'.$locale.$overrides[$name][$locale]; // PL: /pl/wpisy/
    }

    return '/'.$locale.$defaultPath;                   // fallback: /pl/blog/
}
</code></pre>
<ol>
<li><strong>Default locale</strong> — use the attribute's <code>path</code> as-is: <code>/blog/</code></li>
<li><strong>Override exists</strong> — prepend <code>/{locale}</code> + the translated path from <code>_routes.yaml</code></li>
<li><strong>No override</strong> — prepend <code>/{locale}</code> + the default path: <code>/pl/blog/</code></li>
</ol>
<h3><code>_routes.yaml</code> — Translated Path Segments<a id="routesyaml--translated-path-segments" href="#routesyaml--translated-path-segments" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Only routes with translated URL segments need overrides. Routes without entries get auto-prefixed:</p>
<pre><code class="language-yaml">routes:
  blog_list:
    pl: /wpisy/
  blog_list_paginated:
    pl: /wpisy/strona/{page}/
  blog_category:
    pl: /wpisy/{category}/
  blog_archive:
    pl: /archiwum/{year}/{month}/
  contact:
    pl: /kontakt/
  search:
    pl: /szukaj/
</code></pre>
<p><code>blog_tag</code> has no entry, so <code>blog_tag_pl</code> gets the auto-prefix: <code>/pl/tag/{tag}/</code>. Adding German would mean adding <code>de:</code> entries to the routes that need translation, and nothing for routes where the English path is fine.</p>
<h2>Two URL Resolution Patterns<a id="two-url-resolution-patterns" href="#two-url-resolution-patterns" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Templates need to link to pages. There are two fundamentally different cases:</p>
<table>
<thead>
<tr>
<th>Type</th>
<th>Pattern</th>
<th>Example</th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>Structural</strong> (listings, search, contact, archive)</td>
<td><code>path('route_name_' ~ locale)</code></td>
<td><code>path('blog_list_' ~ locale)</code></td>
</tr>
<tr>
<td><strong>Content</strong> (pages, posts, about, privacy)</td>
<td><code>content_url(directoryKey, locale)</code></td>
<td><code>content_url('about', locale)</code></td>
</tr>
</tbody>
</table>
<p>Structural routes come from the router — they're generated by <code>LocalizedRouteLoader</code> and have <code>{name}_{locale}</code> names. Content URLs come from frontmatter <code>slug</code> fields — each <code>en.md</code> and <code>pl.md</code> defines its own URL.</p>
<pre><code class="language-twig">{# Structural: the router knows the path #}
&lt;a href=&quot;{{ path('blog_list_' ~ locale) }}&quot;&gt;Blog&lt;/a&gt;
&lt;a href=&quot;{{ path('contact_' ~ locale) }}&quot;&gt;Contact&lt;/a&gt;

{# Content: look up by directory key #}
&lt;a href=&quot;{{ content_url('about', locale) }}&quot;&gt;About&lt;/a&gt;
&lt;a href=&quot;{{ content_url('privacy-policy', locale) }}&quot;&gt;Privacy&lt;/a&gt;
</code></pre>
<p><code>content_url()</code> is a custom Twig function that looks up a <code>ContentItem</code> by its directory key — the folder name — and returns the URL from its frontmatter. This replaces the old approach of hardcoding slugs per locale in templates.</p>
<h2>Co-located Content — The Filesystem as Translation Link<a id="co-located-content--the-filesystem-as-translation-link" href="#co-located-content--the-filesystem-as-translation-link" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Content files for the same post live in the same directory:</p>
<pre><code>content/blog/tutorials/my-post/
    en.md  → slug: &quot;blog/my-post&quot;
    pl.md  → slug: &quot;pl/blog/moj-wpis&quot;
    files/ → images served at /media/my-post/
</code></pre>
<p>Both files in the same folder are automatically linked as translations. No explicit <code>translation_key</code> field needed. <code>ContentItem::directoryKey()</code> returns the folder name (<code>&quot;my-post&quot;</code>), and <code>TranslationMapBuilder</code> uses it to build a lookup table:</p>
<pre><code class="language-php">public function build(array $trees): array
{
    $map = [];

    foreach ($trees as $locale =&gt; $tree) {
        foreach ($tree-&gt;getAllItems() as $item) {
            $key = $item-&gt;directoryKey();
            if (null === $key || '' === $item-&gt;url()) {
                continue;
            }
            $map[$key][$locale] = $item-&gt;url();
        }
    }

    return $map;
}
</code></pre>
<p>The result: <code>$map['my-post']['en'] = '/blog/my-post/'</code>, <code>$map['my-post']['pl'] = '/pl/blog/moj-wpis/'</code>. This map drives hreflang <code>&lt;link&gt;</code> tags in the HTML head and the language switcher.</p>
<p>Not every post needs both locale files. A post with only <code>en.md</code> won't appear in Polish listings, and the language switcher falls back to the other language's homepage.</p>
<h2>Language Switcher — Fallback Chain<a id="language-switcher--fallback-chain" href="#language-switcher--fallback-chain" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The language switcher seems simple — link to the same page in the other language. In practice, it needs to handle partial translations, tag pages, archive pages, and paginated listings. The fallback chain:</p>
<pre><code class="language-twig">{# 1. Try translation map (page exists in other locale) #}
{% if tk and translation_map[tk][other] is defined %}
    {% set url = translation_map[tk][other] %}
{% endif %}

{# 2. Try controller-provided URL (tag pages) #}
{% if url is null and lang_switch_url is defined %}
    {% set url = lang_switch_url %}
{% endif %}

{# 3. Fallback: archive, paginated listing, or homepage #}
{% if url is null %}
    {% if filter_type is same as('archive') and archive_year is defined %}
        {% set url = path('blog_archive_' ~ other, {year: ..., month: ...}) %}
    {% elseif current_page &gt; 1 %}
        {% set url = path('blog_list_paginated_' ~ other, {page: current_page}) %}
    {% else %}
        {% set url = path('home_' ~ other) %}
    {% endif %}
{% endif %}
</code></pre>
<p>Tag pages get special treatment: <code>BlogController</code> translates the tag slug between locales (e.g., <code>security</code> → <code>bezpieczenstwo</code>) and checks whether the other locale has any posts with that tag. If yes, the switcher links to the translated tag page. If no, it falls back to the other locale's blog listing.</p>
<p>On the client side, <code>locale-redirect.js</code> handles first-visit language detection. It reads <code>navigator.language</code>, matches it against the configured locale list (from a <code>data-locales</code> attribute on <code>&lt;html&gt;</code>), and stores the preference in a cookie. On return visits, it redirects to the saved preference. The locale list isn't hardcoded in JavaScript — it comes from the same <code>_site.yaml</code> config, passed through Twig.</p>
<h2>Locale Detection<a id="locale-detection" href="#locale-detection" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>LocaleListener</code> runs at priority 8 — after Symfony's built-in locale listeners — and detects locale from the URL path:</p>
<pre><code class="language-php">private function detectLocale(string $path): string
{
    $defaultLocale = $this-&gt;siteConfig-&gt;getDefaultLocale();

    foreach ($this-&gt;siteConfig-&gt;getLocales() as $locale) {
        if ($locale === $defaultLocale) {
            continue;
        }

        if (str_starts_with($path, '/'.$locale.'/') || '/'.$locale === $path) {
            return $locale;
        }
    }

    return $defaultLocale;
}
</code></pre>
<p>No hardcoded <code>/pl/</code> check. It iterates the configured locales dynamically. Adding a new locale to <code>_site.yaml</code> is enough for the listener to start detecting it.</p>
<h2>Adding a New Language — Zero PHP Changes<a id="adding-a-new-language--zero-php-changes" href="#adding-a-new-language--zero-php-changes" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This is the payoff. Adding German to holas.pl requires:</p>
<ol>
<li><strong><code>content/_site.yaml</code></strong> — add a <code>de:</code> entry with label, og_locale, date_format</li>
<li><strong><code>content/_routes.yaml</code></strong> — add <code>de:</code> overrides for translated route segments</li>
<li><strong><code>translations/messages.de.yaml</code></strong> — German UI strings (nav labels, button text, etc.)</li>
<li><strong><code>content/_tags.yaml</code></strong> — German tag translations</li>
<li><strong>Content files</strong> — create <code>de.md</code> alongside <code>en.md</code> and <code>pl.md</code> for posts that should exist in German</li>
</ol>
<p>No PHP files touched. No Twig templates edited. No controller methods added. The route loader generates German routes automatically. The locale listener detects <code>/de/</code> paths. The language switcher renders a dropdown instead of a toggle link. The translation map includes German URLs.</p>
<p>The 28 locale-specific controller methods and 30 hardcoded locale checks from the first version would have meant editing 20+ files to add German. The configuration-driven approach means editing 5 config files and creating content.</p>
<p>The architecture decisions that make this work — <code>SiteConfigService</code> as the single locale authority, <code>LocalizedRouteLoader</code> generating routes from attributes, co-located content as the translation link — are the kind of decisions that seem like over-engineering when you only have two languages. They're not. They're the difference between &quot;adding a language is a week of work&quot; and &quot;adding a language is an afternoon of config.&quot;</p>
<p><a href="/blog/iterating-architecture-with-ai/">Next post</a> covers how the codebase improved through iterative quality rounds — value objects, interfaces, component extraction — with AI handling the systematic review work.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/multilanguage-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Responsive Images and Scheduled Posts on a Static Site — Build-Time Solutions]]></title>
            <link>https://holas.pl/blog/responsive-images-scheduled-posts/</link>
            <guid isPermaLink="true">https://holas.pl/blog/responsive-images-scheduled-posts/</guid>
                        <pubDate>Mon, 25 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[A WordPress site resizes uploaded images automatically. Scheduled posts have a &amp;quot;publish on&amp;quot; date picker in the editor. Both work without any custom code. On a static site, there&#039;s no server handling requests and no application layer checking the clock. Both features require deliberate implementation — and the right place for both is the build step. Responsive Images The Problem Featured …]]></description>
            <content:encoded><![CDATA[<p>A WordPress site resizes uploaded images automatically. Scheduled posts have a &quot;publish on&quot; date picker in the editor. Both work without any custom code.</p>
<p>On a static site, there's no server handling requests and no application layer checking the clock. Both features require deliberate implementation — and the right place for both is the build step.</p>
<h2>Responsive Images<a id="responsive-images" href="#responsive-images" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>The Problem<a id="the-problem" href="#the-problem" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Featured images on holas.pl are 1280×720px WebP. On a desktop browser, that's the right size. On a mobile screen 400px wide, the browser downloads a 1280-pixel image to display at 400 pixels — roughly 10× the data actually needed.</p>
<p>The solution is <code>srcset</code> + <code>sizes</code>: tell the browser what image variants exist and how large the image renders at each viewport width, then let it pick the right file. The static site constraint: every variant must exist as a file before any request arrives. There's no resize-on-demand.</p>
<h3>Build-Time Variant Generation<a id="build-time-variant-generation" href="#build-time-variant-generation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand</code> generates variants after copying media files to <code>public/static/media/</code>. It scans for <code>.webp</code> files, reads each file's actual dimensions with <code>getimagesize()</code>, and generates variants via <code>ImageResizerInterface::resize()</code>:</p>
<pre><code class="language-php">$variantWidths = $this-&gt;responsiveImageService-&gt;getVariantWidths($width);

foreach ($variantWidths as $variantWidth) {
    $this-&gt;imageResizer-&gt;resize(
        $filePath,
        $dir . '/' . $baseName . '-' . $variantWidth . 'w.webp',
        $variantWidth,
    );
}
</code></pre>
<p><code>ImageResizer::resize()</code> calls ImageMagick:</p>
<pre><code class="language-bash">magick source.webp -resize 640x -quality 82 -strip -define webp:method=6 source-640w.webp
</code></pre>
<p><code>-resize 640x</code> scales to 640px wide, preserving aspect ratio. <code>-quality 82 -strip -define webp:method=6</code> matches the production image settings and removes EXIF data.</p>
<p>Variant filenames follow a convention: <code>image.webp</code> → <code>image-640w.webp</code>, <code>image-960w.webp</code>. The build skips files that already end in <code>-640w</code> or <code>-960w</code> to avoid re-processing previously generated variants.</p>
<h3>ResponsiveImageService<a id="responsiveimageservice" href="#responsiveimageservice" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Two places need to know which variants exist: <code>BuildStaticSiteCommand</code> (which files to generate) and <code>SrcsetExtension</code> (which filenames to reference in HTML). Rather than duplicating the breakpoint logic, both inject <code>ResponsiveImageServiceInterface</code>:</p>
<pre><code class="language-php">interface ResponsiveImageServiceInterface
{
    /** @return int[] */
    public function getVariantWidths(int $sourceWidth): array;

    public function buildSrcset(string $src, int $sourceWidth): string;
}
</code></pre>
<p>The implementation:</p>
<pre><code class="language-php">public function getVariantWidths(int $sourceWidth): array
{
    if (960 &lt; $sourceWidth) {
        return [640, 960];
    }
    if (640 &lt; $sourceWidth) {
        return [640];
    }

    return [];
}

public function buildSrcset(string $src, int $sourceWidth): string
{
    $base = substr($src, 0, -5);  // strip .webp

    if (960 &lt; $sourceWidth) {
        return sprintf('%s-640w.webp 640w, %s-960w.webp 960w, %s 1280w', $base, $base, $src);
    }
    if (640 &lt; $sourceWidth) {
        return sprintf('%s-640w.webp 640w, %s 960w', $base, $src);
    }

    return '';
}
</code></pre>
<p>Images ≤640px wide get no variants — the original is already small enough. <code>buildSrcset()</code> returns <code>''</code> to signal that no srcset attribute is needed.</p>
<p>If breakpoints ever need to change, there's one place to update.</p>
<h3>Featured Image Component<a id="featured-image-component" href="#featured-image-component" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The <code>responsive_img.html.twig</code> component renders featured images with srcset hardcoded to the 640/960/1280 breakpoints:</p>
<pre><code class="language-twig">&lt;img src=&quot;{{ src }}&quot;
     srcset=&quot;{{ src|replace({'.webp': '-640w.webp'}) }} 640w,
             {{ src|replace({'.webp': '-960w.webp'}) }} 960w,
             {{ src }} 1280w&quot;
     sizes=&quot;{{ sizes|default('(max-width: 48em) 100vw, 720px') }}&quot;
     alt=&quot;{{ alt }}&quot;
     width=&quot;{{ width|default(1280) }}&quot;
     height=&quot;{{ height|default(720) }}&quot;&gt;
</code></pre>
<p><code>sizes=&quot;(max-width: 48em) 100vw, 720px&quot;</code> tells the browser: below 48em viewport width, the image fills the full viewport; above that, it's constrained to 720px (the content column width). The browser uses this to pick the right srcset entry before downloading anything.</p>
<p><code>width</code> and <code>height</code> are explicit for Cumulative Layout Shift prevention — the browser reserves the exact space for the image before it loads. Without them, the layout shifts when the image arrives.</p>
<h3>Inline Content Images<a id="inline-content-images" href="#inline-content-images" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Markdown images inside post body render as plain <code>&lt;img&gt;</code> tags. No srcset. A post with a diagram or screenshot at <code>/media/post-dir/diagram.webp</code> would serve the full-size image to mobile too.</p>
<p>The <code>srcset_media</code> Twig filter handles this. In <code>post.html.twig</code>:</p>
<pre><code class="language-twig">{{ content.htmlContent|srcset_media|raw }}
</code></pre>
<p><code>SrcsetExtension::srcsetMedia()</code> finds all <code>/media/*.webp</code> <code>&lt;img&gt;</code> tags with a regex, reads the source image width from the content directory (not the static output), and injects <code>srcset</code> and <code>sizes</code>:</p>
<pre><code class="language-php">$result = preg_replace_callback(
    '/&lt;img(\s[^&gt;]*)src=&quot;(\/media\/[^&quot;]+\.webp)&quot;([^&gt;]*)&gt;/i',
    function (array $matches): string {
        $src = $matches[2];

        // skip if srcset already present
        if (str_contains($matches[1], 'srcset') || str_contains($matches[3], 'srcset')) {
            return $matches[0];
        }

        $width = $this-&gt;getSourceWidth($src);
        if (null === $width) {
            return $matches[0];
        }

        $srcset = $this-&gt;responsiveImageService-&gt;buildSrcset($src, $width);
        if ('' === $srcset) {
            return $matches[0];  // no variants generated — leave as-is
        }

        return sprintf(
            '&lt;img%ssrc=&quot;%s&quot; srcset=&quot;%s&quot; sizes=&quot;(max-width: 48em) 100vw, 720px&quot;%s&gt;',
            $matches[1], $src, $srcset, $matches[3],
        );
    },
    $html,
);
</code></pre>
<p><code>getSourceWidth()</code> looks up the actual source file under <code>content/</code> (not <code>public/static/</code>), since that's where the original dimensions live. Images with no variants — small inline screenshots ≤640px wide — are left unchanged.</p>
<h2>Scheduled Posts<a id="scheduled-posts" href="#scheduled-posts" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>The Problem<a id="the-problem-1" href="#the-problem-1" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>A post with <code>date: 2026-06-01</code> shouldn't appear in listings until June 1. The site rebuilds nightly, so a future-dated post simply won't appear in <code>ContentTree::getAllPosts()</code> until the build after its publish date. That part is automatic.</p>
<p>The URL is a different problem. If someone shares the link before the post is live, they get a 404. Better to serve a &quot;coming soon&quot; page at the exact URL the post will occupy.</p>
<h3>ContentItem::isScheduled()<a id="contentitemisscheduled" href="#contentitemisscheduled" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-php">public function isScheduled(): bool
{
    $date = $this-&gt;date();

    return null !== $date &amp;&amp; $date &gt; new \DateTimeImmutable();
}
</code></pre>
<p>One comparison. <code>isDraft()</code> takes priority — a post with both <code>draft: true</code> and a future date is treated as a draft and excluded from all builds.</p>
<h3>Static Build: Coming-Soon Pages<a id="static-build-coming-soon-pages" href="#static-build-coming-soon-pages" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p><code>BuildStaticSiteCommand::collectRoutes()</code> collects two categories of post URLs:</p>
<ul>
<li>Published posts via <code>ContentTree::getAllPosts()</code> — rendered with the full post template</li>
<li>Scheduled posts via <code>ContentTree::getScheduledPosts()</code> — rendered with the coming-soon template</li>
</ul>
<p>Both produce static HTML files at their eventual URL. When the post's date passes and the next build runs, <code>isScheduled()</code> returns <code>false</code>, the URL moves to the published list, and the full post HTML replaces the coming-soon HTML. No redirect, no special handling needed.</p>
<h3>The Coming-Soon Page<a id="the-coming-soon-page" href="#the-coming-soon-page" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The coming-soon template uses the same green terminal aesthetic as the error pages:</p>
<pre><code class="language-twig">{% block robots %}&lt;meta name=&quot;robots&quot; content=&quot;noindex, nofollow&quot;&gt;{% endblock %}

&lt;pre class=&quot;coming-soon-terminal&quot;&gt;&lt;code&gt;
&lt;span class=&quot;coming-soon-terminal__code&quot;&gt;COMING_SOON&lt;/span&gt;
{% if days_until &lt;= 14 %}
&lt;span class=&quot;coming-soon-terminal__text&quot;&gt;{{ post.title }}&lt;/span&gt;
&lt;span class=&quot;coming-soon-terminal__date&quot;&gt;Publishing: {{ post.date|date('Y-m-d') }}&lt;/span&gt;
{% endif %}
&lt;/code&gt;&lt;/pre&gt;
</code></pre>
<p><code>noindex, nofollow</code> — the page handles direct links gracefully without ranking or passing link equity.</p>
<p>If the publish date is ≤14 days away, the title and date are shown. Further out: just the <code>COMING_SOON</code> code, no date. The 14-day threshold avoids making a public commitment to a specific date that might slip.</p>
<h3>Dev Preview Toolbar<a id="dev-preview-toolbar" href="#dev-preview-toolbar" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>In production, scheduled posts are invisible — they appear only as coming-soon pages at their URLs, not in any listing.</p>
<p>In development, you're writing scheduled post content and need to see it. The Symfony profiler toolbar gets a calendar icon toggle (&quot;Scheduled preview&quot;). When on, scheduled posts appear in listings with a <code>[PLANNED]</code> badge:</p>
<pre><code class="language-twig">{% if post.isDraft() %}
    &lt;span class=&quot;post-card-badge post-card-badge--draft&quot;&gt;[DRAFT]&lt;/span&gt;
{% elseif post.isScheduled() %}
    &lt;span class=&quot;post-card-badge post-card-badge--planned&quot;&gt;[PLANNED]&lt;/span&gt;
{% else %}
    {# pinned / new / recently updated badges #}
{% endif %}
</code></pre>
<p>Toggle it off to preview what production will look like. The mechanism mirrors the existing draft preview toggle exactly — same session key pattern (<code>scheduled_preview</code>), same controller structure.</p>
<h2>The Build-Step Pattern<a id="the-build-step-pattern" href="#the-build-step-pattern" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both features follow the same approach: push work into the build step, keep the serving layer simple.</p>
<p>Responsive images: generate all variants at build time. A few seconds of ImageMagick calls during <code>ddev build</code> saves bandwidth on every mobile page load for the lifetime of the post.</p>
<p>Scheduled posts: pre-render coming-soon pages rather than handling &quot;not yet published&quot; at request time. The static file exists, nginx serves it, no PHP involved.</p>
<p>The build runs once. nginx serves the result to every visitor. Any work that can move to the build step is work the server doesn't have to do.</p>
<p>The build pipeline that makes this possible is covered in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a>. The two-container production setup that runs it is in <a href="/blog/dev-experience-two-containers/">Part 3</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/responsive-images-scheduled-posts/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[php]]></category>
                    </item>
                <item>
            <title><![CDATA[SEO Engineering on a Static Site — Structured Data, Social Cards, and Crawler Signals]]></title>
            <link>https://holas.pl/blog/seo-engineering-static-site/</link>
            <guid isPermaLink="true">https://holas.pl/blog/seo-engineering-static-site/</guid>
                        <pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl scores 100 in Lighthouse&#039;s SEO category. What that actually checks: meta title is present, meta description is present, canonical URL is set, links are crawlable, the page is mobile-friendly. These are the minimum requirements — the things that block indexing if they&#039;re missing. What Lighthouse SEO doesn&#039;t check: whether your structured data is complete, how your page renders as a social …]]></description>
            <content:encoded><![CDATA[<p>holas.pl scores 100 in Lighthouse's SEO category. What that actually checks: meta title is present, meta description is present, canonical URL is set, links are crawlable, the page is mobile-friendly. These are the minimum requirements — the things that block indexing if they're missing.</p>
<p>What Lighthouse SEO doesn't check: whether your structured data is complete, how your page renders as a social card, what feed readers see when they subscribe, whether Google can find and index your images without crawling every page.</p>
<p>This post covers the implementation layer beneath that score.</p>
<h2>Structured Data<a id="structured-data" href="#structured-data" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Structured data is JSON-LD in a <code>&lt;script type=&quot;application/ld+json&quot;&gt;</code> block. It tells search engines what a page is, not just what it says. holas.pl uses four schema types.</p>
<h3>WebSite + SearchAction<a id="website--searchaction" href="#website--searchaction" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Every page carries a <code>WebSite</code> schema identifying the site and its search endpoint:</p>
<pre><code class="language-json">{
    &quot;@context&quot;: &quot;https://schema.org&quot;,
    &quot;@type&quot;: &quot;WebSite&quot;,
    &quot;name&quot;: &quot;holas.pl&quot;,
    &quot;url&quot;: &quot;https://holas.pl&quot;,
    &quot;author&quot;: {
        &quot;@type&quot;: &quot;Person&quot;,
        &quot;name&quot;: &quot;Paweł Holik&quot;,
        &quot;url&quot;: &quot;https://holas.pl&quot;
    },
    &quot;potentialAction&quot;: {
        &quot;@type&quot;: &quot;SearchAction&quot;,
        &quot;target&quot;: {
            &quot;@type&quot;: &quot;EntryPoint&quot;,
            &quot;urlTemplate&quot;: &quot;https://holas.pl/search/?q={search_term_string}&quot;
        },
        &quot;query-input&quot;: &quot;required name=search_term_string&quot;
    }
}
</code></pre>
<p>The <code>potentialAction</code> enables the <a rel="nofollow noopener noreferrer" target="_blank" href="https://developers.google.com/search/docs/appearance/sitelinks-searchbox">Google Sitelinks search box</a> — a search input that appears directly in the Google result for the site. It maps to the Pagefind-powered search at <code>/search/</code>. This is one extra field on an existing schema with no downside.</p>
<h3>BlogPosting<a id="blogposting" href="#blogposting" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Blog posts carry the richest schema. Beyond <code>headline</code>, <code>description</code>, <code>url</code>, and <code>datePublished</code>, several fields matter for how Google represents the content:</p>
<ul>
<li><strong><code>inLanguage</code></strong> — <code>&quot;en&quot;</code> or <code>&quot;pl&quot;</code>, needed for multilingual indexing</li>
<li><strong><code>wordCount</code></strong> — computed at parse time by <code>ContentItem::wordCount()</code> (strips HTML tags, counts tokens)</li>
<li><strong><code>articleSection</code></strong> — the post category</li>
<li><strong><code>keywords</code></strong> — the post tags as a comma-separated string</li>
<li><strong><code>image</code></strong> — a nested <code>ImageObject</code> with <code>url</code>, <code>width</code>, and <code>height</code></li>
</ul>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;BlogPosting&quot;,
    &quot;headline&quot;: &quot;Post title&quot;,
    &quot;inLanguage&quot;: &quot;en&quot;,
    &quot;wordCount&quot;: 842,
    &quot;articleSection&quot;: &quot;tutorials&quot;,
    &quot;keywords&quot;: &quot;seo, symfony, static-site&quot;,
    &quot;image&quot;: {
        &quot;@type&quot;: &quot;ImageObject&quot;,
        &quot;url&quot;: &quot;https://holas.pl/media/post-dir/featured.webp&quot;,
        &quot;width&quot;: 1280,
        &quot;height&quot;: 720
    }
}
</code></pre>
<p>Without <code>ImageObject</code>, Google treats the featured image as an unknown attachment. With width and height explicitly set, the image becomes eligible for large preview cards in Google Discover and Search.</p>
<h3>BreadcrumbList<a id="breadcrumblist" href="#breadcrumblist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Google can replace the raw URL in search results with breadcrumb navigation — &quot;Home / Blog / tutorials / Post Title&quot;. This requires <code>BreadcrumbList</code> schema.</p>
<p>It's rendered in <code>breadcrumb.html.twig</code> alongside the HTML nav. Each crumb is a <code>ListItem</code> with <code>position</code> and <code>item</code> (URL). The last item — the current page — has a name but no URL:</p>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;BreadcrumbList&quot;,
    &quot;itemListElement&quot;: [
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;name&quot;: &quot;Home&quot;, &quot;item&quot;: &quot;https://holas.pl/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 2, &quot;name&quot;: &quot;Blog&quot;, &quot;item&quot;: &quot;https://holas.pl/blog/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 3, &quot;name&quot;: &quot;tutorials&quot;, &quot;item&quot;: &quot;https://holas.pl/blog/tutorials/&quot; },
        { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 4, &quot;name&quot;: &quot;Post Title&quot; }
    ]
}
</code></pre>
<h3>CollectionPage + ItemList<a id="collectionpage--itemlist" href="#collectionpage--itemlist" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Category, tag, and archive listing pages carry <code>CollectionPage</code> with a nested <code>ItemList</code>. Each entry has a <code>position</code> and <code>url</code>. Only rendered when the listing has posts — an empty category page doesn't get it.</p>
<pre><code class="language-json">{
    &quot;@type&quot;: &quot;CollectionPage&quot;,
    &quot;name&quot;: &quot;tutorials | holas.pl&quot;,
    &quot;mainEntity&quot;: {
        &quot;@type&quot;: &quot;ItemList&quot;,
        &quot;numberOfItems&quot;: 5,
        &quot;itemListElement&quot;: [
            { &quot;@type&quot;: &quot;ListItem&quot;, &quot;position&quot;: 1, &quot;url&quot;: &quot;https://holas.pl/blog/post-name/&quot; }
        ]
    }
}
</code></pre>
<h2>Social Sharing<a id="social-sharing" href="#social-sharing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>OpenGraph Image Dimensions<a id="opengraph-image-dimensions" href="#opengraph-image-dimensions" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Without <code>og:image:width</code> and <code>og:image:height</code>, platforms like LinkedIn and Slack must fetch the image before rendering the preview card. With them, the card renders immediately:</p>
<pre><code class="language-html">&lt;!-- blog post (WebP, 1280×720) --&gt;
&lt;meta property=&quot;og:image:width&quot; content=&quot;1280&quot;&gt;
&lt;meta property=&quot;og:image:height&quot; content=&quot;720&quot;&gt;
&lt;meta property=&quot;og:image:type&quot; content=&quot;image/webp&quot;&gt;

&lt;!-- other pages (default og:image, JPG, 1200×630) --&gt;
&lt;meta property=&quot;og:image:width&quot; content=&quot;1200&quot;&gt;
&lt;meta property=&quot;og:image:height&quot; content=&quot;630&quot;&gt;
&lt;meta property=&quot;og:image:type&quot; content=&quot;image/jpeg&quot;&gt;
</code></pre>
<p>The conditional is in <code>base.html.twig</code>: if a <code>content</code> object with an image is defined (blog post or page with featured image), use the WebP dimensions; otherwise use the defaults for <code>og-default.jpg</code>. The JPG exception exists because <code>og:image</code> is consumed by external crawlers that don't reliably support WebP.</p>
<h3>Twitter/X Card<a id="twitterx-card" href="#twitterx-card" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The base <code>twitter:card</code> type was already present. Three explicit fields were added:</p>
<pre><code class="language-html">&lt;meta name=&quot;twitter:title&quot; content=&quot;...&quot;&gt;
&lt;meta name=&quot;twitter:description&quot; content=&quot;...&quot;&gt;
&lt;meta name=&quot;twitter:image&quot; content=&quot;...&quot;&gt;
</code></pre>
<p>Without them, Twitter/X falls back to OG properties. Explicit meta removes that dependency — if OG processing has any issue, Twitter Card still has the correct values.</p>
<h3>article:* Meta<a id="article-meta" href="#article-meta" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Blog posts get article-specific OG meta in <code>post.html.twig</code>'s <code>og_article_meta</code> block:</p>
<pre><code class="language-html">&lt;meta property=&quot;article:published_time&quot; content=&quot;2026-06-21T00:00:00+00:00&quot;&gt;
&lt;meta property=&quot;article:modified_time&quot; content=&quot;2026-06-21T00:00:00+00:00&quot;&gt;
&lt;meta property=&quot;article:author&quot; content=&quot;Paweł Holik&quot;&gt;
&lt;meta property=&quot;article:section&quot; content=&quot;tutorials&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;seo&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;symfony&quot;&gt;
&lt;meta property=&quot;article:tag&quot; content=&quot;static-site&quot;&gt;
</code></pre>
<p><code>article:tag</code> is one element per tag — not comma-separated. The Open Graph spec requires separate elements for multi-value properties.</p>
<h2>Feed Readers<a id="feed-readers" href="#feed-readers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>content:encoded<a id="contentencoded" href="#contentencoded" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The default RSS <code>&lt;description&gt;</code> contains only the post excerpt — first paragraph, HTML stripped. <code>content:encoded</code> carries the full post HTML in a CDATA block:</p>
<pre><code class="language-xml">&lt;content:encoded&gt;&lt;![CDATA[&lt;p&gt;Full post content...&lt;/p&gt;]]&gt;&lt;/content:encoded&gt;
</code></pre>
<p>This requires <code>xmlns:content=&quot;http://purl.org/rss/1.0/modules/content/&quot;</code> on the root <code>&lt;rss&gt;</code> element. Feed readers like NetNewsWire, Reeder, and Feedbin render <code>content:encoded</code> inline — subscribers read the full article without leaving their reader.</p>
<h3>category and media:content<a id="category-and-mediacontent" href="#category-and-mediacontent" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Each RSS item gets <code>&lt;category&gt;</code> elements for the post category and each tag:</p>
<pre><code class="language-xml">&lt;category&gt;tutorials&lt;/category&gt;
&lt;category&gt;seo&lt;/category&gt;
&lt;category&gt;symfony&lt;/category&gt;
</code></pre>
<p><code>media:content</code> attaches the featured image as a typed media attachment:</p>
<pre><code class="language-xml">&lt;media:content url=&quot;https://holas.pl/media/post-dir/featured.webp&quot;
               medium=&quot;image&quot; type=&quot;image/webp&quot; width=&quot;1280&quot; height=&quot;720&quot;/&gt;
</code></pre>
<p>Feed readers that render inline images (Feedly, Inoreader) use this for the post thumbnail in the feed list. This requires <code>xmlns:media=&quot;http://search.yahoo.com/mrss/&quot;</code> on the <code>&lt;rss&gt;</code> element.</p>
<h2>Crawling Signals<a id="crawling-signals" href="#crawling-signals" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<h3>max-image-preview:large<a id="max-image-previewlarge" href="#max-image-previewlarge" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The default robots behavior limits image previews in Google Search and Discover to standard size. <code>max-image-preview:large</code> opts into full-size previews. Combined with <code>max-snippet:-1</code> (no restriction on text snippet length), this is the default robots meta on every page:</p>
<pre><code class="language-html">&lt;meta name=&quot;robots&quot; content=&quot;max-image-preview:large, max-snippet:-1&quot;&gt;
</code></pre>
<p>Implemented as the default <code>{% block robots %}</code> in <code>base.html.twig</code>. Child templates override the block for pages that shouldn't be indexed — coming-soon pages use <code>noindex, nofollow</code>, the search page uses <code>noindex</code>.</p>
<h3>Image Sitemap<a id="image-sitemap" href="#image-sitemap" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The standard sitemap lists page URLs. The image sitemap adds <code>&lt;image:image&gt;</code> blocks, giving Google direct visibility into image locations and alt text without crawling every page first:</p>
<pre><code class="language-xml">&lt;urlset xmlns=&quot;http://www.sitemaps.org/schemas/sitemap/0.9&quot;
        xmlns:image=&quot;http://www.google.com/schemas/sitemap-image/1.1&quot;&gt;
    &lt;url&gt;
        &lt;loc&gt;https://holas.pl/blog/post-name/&lt;/loc&gt;
        &lt;image:image&gt;
            &lt;image:loc&gt;https://holas.pl/media/post-name/featured.webp&lt;/image:loc&gt;
            &lt;image:title&gt;Alt text from image_alt frontmatter&lt;/image:title&gt;
        &lt;/image:image&gt;
    &lt;/url&gt;
</code></pre>
<p><code>image:title</code> comes from the <code>image_alt</code> frontmatter field — the same text used in the HTML <code>alt</code> attribute. Both the <code>xmlns:image</code> namespace and the <code>image:image</code> block are in <code>sitemap.xml.twig</code>.</p>
<h2>What Changed<a id="what-changed" href="#what-changed" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The Lighthouse SEO score was 100 before these changes. It's still 100 after them. That score measures the technical floor: crawlability, meta tags, mobile-friendliness.</p>
<p>The changes above operate at a different level. Structured data shapes how search engines represent content in rich results. Explicit social meta ensures correct rendering without relying on platform fallback logic. RSS extensions let subscribers read full posts in their reader. The image sitemap gives Google image visibility without requiring a crawl of every page.</p>
<p>None of it is architecturally complex — most of it is Twig template additions and namespace declarations. The constraint is discipline: every field needs a real value from frontmatter, not a placeholder.</p>
<p>The architecture that makes all of this straightforward is covered in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a> — the static generation pipeline that produces complete HTML for every page.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/seo-engineering-static-site/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[seo]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[4×100 on Lighthouse Mobile — What a Static Site Actually Gets You]]></title>
            <link>https://holas.pl/blog/lighthouse-perfect-score/</link>
            <guid isPermaLink="true">https://holas.pl/blog/lighthouse-perfect-score/</guid>
                        <pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl scores 100 in all four Lighthouse categories on mobile — Performance, Accessibility, Best Practices, and SEO. Mobile is the stricter test: slower simulated CPU, throttled network, tighter scoring thresholds. Getting all four to 100 there means desktop takes care of itself. This post goes through what actually drives each number. Most of it isn&#039;t optimisation work — it&#039;s a side effect of h…]]></description>
            <content:encoded><![CDATA[<p>holas.pl scores 100 in all four Lighthouse categories on mobile — <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagespeed.web.dev/analysis/https-holas-pl/g7kl99oxfg?form_factor=mobile">Performance, Accessibility, Best Practices, and SEO</a>. Mobile is the stricter test: slower simulated CPU, throttled network, tighter scoring thresholds. Getting all four to 100 there means desktop takes care of itself.</p>
<p><img src="/media/lighthouse-perfect-score/lighthouse-scores.webp" alt="Lighthouse mobile: 4×100 — Performance, Accessibility, Best Practices, SEO" /></p>
<p>This post goes through what actually drives each number. Most of it isn't optimisation work — it's a side effect of how the site is built.</p>
<h2>Performance<a id="performance" href="#performance" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The main reason the performance score is 100 is that nginx serves pre-rendered HTML files with no PHP involved. There is no database query, no template rendering, no framework bootstrap on every request. A file comes off disk and goes to the client. A Raspberry Pi 5 handles this without breaking a sweat.</p>
<p>Everything else follows from there:</p>
<p><strong>Assets are hashed and immutable.</strong> JavaScript and CSS files compiled by Symfony's AssetMapper get a content hash in the filename (<code>app-a1b2c3d4.css</code>). nginx serves them with <code>Cache-Control: public, max-age=31536000, immutable</code> — one year, no revalidation. On repeat visits, the browser serves everything from cache. On deploy, the hash changes and the new file is fetched.</p>
<p><strong>JavaScript is minimal and non-blocking.</strong> The site uses native ES module imports via a browser-native importmap — no bundler, no webpack, no jQuery. There are seven small JS files: <code>app.js</code>, <code>contact.js</code>, <code>cookie-banner.js</code>, <code>lightbox.js</code>, <code>locale-redirect.js</code>, <code>nav-toggle.js</code>, <code>tagline.js</code>. None of them block rendering. Search is powered by <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a>, a WASM-based static search index that loads lazily — only on the search page, only when needed.</p>
<p><strong>Images are WebP.</strong> Featured images are stored as WebP at 1280×720. No large uncompressed JPEGs.</p>
<p><strong>No render-blocking resources.</strong> There is no <code>&lt;link rel=&quot;stylesheet&quot;&gt;</code> to an external font CDN, no synchronous third-party script loaded in <code>&lt;head&gt;</code>. The CSS is compiled locally and served as a hashed asset.</p>
<h2>Accessibility<a id="accessibility" href="#accessibility" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong><code>&lt;html lang&gt;</code> is set per locale.</strong> Every page has the correct language attribute — <code>lang=&quot;en&quot;</code> for English pages, <code>lang=&quot;pl&quot;</code> for Polish. This is set in the base Twig template based on the current locale, not hardcoded.</p>
<p><strong>Semantic HTML throughout.</strong> The layout uses <code>&lt;nav&gt;</code>, <code>&lt;main&gt;</code>, <code>&lt;article&gt;</code>, <code>&lt;aside&gt;</code>, <code>&lt;footer&gt;</code> — not a sequence of <code>&lt;div&gt;</code> elements. Headings follow a logical hierarchy: one <code>&lt;h1&gt;</code> per page, <code>&lt;h2&gt;</code> for top-level sections, <code>&lt;h3&gt;</code> below that.</p>
<p><strong>Color contrast passes.</strong> The site uses a Monokai-derived dark palette: <code>#F8F8F2</code> text on <code>#242424</code> background. That's a contrast ratio of 15.5:1, well above the WCAG AA threshold of 4.5:1.</p>
<p><strong>All images have alt attributes.</strong> This is enforced in the Twig templates — the <code>&lt;img&gt;</code> tag always outputs the alt text from the content item's frontmatter.</p>
<p><strong>Viewport meta tag is present.</strong> Every page includes <code>&lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1&quot;&gt;</code>.</p>
<h2>Best Practices<a id="best-practices" href="#best-practices" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>HTTPS.</strong> The site runs behind Cloudflare, which handles TLS termination. All HTTP requests are redirected to HTTPS.</p>
<p><strong>Security headers.</strong> nginx sets the full set on every response:</p>
<pre><code class="language-nginx">add_header X-Frame-Options           &quot;SAMEORIGIN&quot;                       always;
add_header X-Content-Type-Options    &quot;nosniff&quot;                          always;
add_header Referrer-Policy           &quot;strict-origin-when-cross-origin&quot;  always;
add_header Permissions-Policy        &quot;camera=(), microphone=(), geolocation=()&quot; always;
add_header Content-Security-Policy   &quot;default-src 'self'; ...&quot; always;
</code></pre>
<p>The CSP required some care — <code>wasm-unsafe-eval</code> for Pagefind's WASM bundle, and <code>challenges.cloudflare.com</code> as an allowed frame source for the Turnstile CAPTCHA on the contact form. Everything else is <code>'self'</code>.</p>
<p><strong>No deprecated APIs.</strong> The site doesn't use <code>document.write</code>, <code>XMLHttpRequest</code>, <code>&lt;table&gt;</code> layouts, or anything else Lighthouse flags as a deprecated practice.</p>
<p><strong>No mixed content.</strong> Every external resource (Cloudflare Turnstile script) is loaded over HTTPS.</p>
<h2>SEO<a id="seo" href="#seo" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Pre-rendered HTML.</strong> Crawlers receive complete HTML — every heading, paragraph, code block, and link is in the source. There is no client-side rendering to wait for, no JavaScript required to see the content.</p>
<p><strong>Sitemap with hreflang.</strong> The sitemap at <code>/sitemap.xml</code> lists all posts and pages for both locales. Each entry includes <code>&lt;xhtml:link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> pairs pointing to the EN and PL versions. If a post exists only in one language, the alternate entry is omitted.</p>
<p><strong>hreflang in <code>&lt;head&gt;</code>.</strong> Every page includes <code>&lt;link rel=&quot;alternate&quot; hreflang=&quot;...&quot;&gt;</code> tags for both locales. The language switcher uses the same translation map — built from co-located <code>en.md</code>/<code>pl.md</code> files in each post directory.</p>
<p><strong>Canonical URLs.</strong> Each page includes <code>&lt;link rel=&quot;canonical&quot; href=&quot;...&quot;&gt;</code> pointing to the authoritative URL for that page.</p>
<p><strong>OpenGraph meta.</strong> Every page has <code>og:title</code>, <code>og:description</code>, <code>og:image</code>, and <code>og:url</code>. These are populated from frontmatter — <code>title</code>, <code>description</code>, and <code>image</code> fields map directly to the OG tags in the base template.</p>
<p><strong>Descriptive titles and meta descriptions.</strong> Frontmatter <code>title</code> and <code>description</code> fields are required. The site doesn't have any pages with default or missing meta descriptions.</p>
<h2>What wasn't automatic<a id="what-wasnt-automatic" href="#what-wasnt-automatic" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Most of the above follows from the architecture — static files, minimal JS, pre-rendered HTML. But a few things required deliberate work.</p>
<p><strong>Accessibility attributes.</strong> The <code>lang</code> attribute, alt text enforcement, heading hierarchy, and semantic element choices all had to be written into the templates. They don't appear by themselves.</p>
<p><strong>The CSP.</strong> Getting the Content-Security-Policy right took iteration. Pagefind uses WebAssembly, which requires <code>wasm-unsafe-eval</code>. Cloudflare Turnstile loads from <code>challenges.cloudflare.com</code> and needs a frame-src exception. Every third-party resource requires an explicit CSP exception — adding one without checking breaks the score.</p>
<p><strong>hreflang.</strong> The <code>TranslationMapBuilder</code> service builds the <code>{directoryKey → {locale → url}}</code> map from co-located content files. If a post exists only in one locale, the hreflang entry for the missing locale is omitted rather than pointing to a non-existent URL. This required a deliberate fallback in the template, not just a loop over all locales.</p>
<h2>The result<a id="the-result" href="#the-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The 4×100 isn't the outcome of an optimisation sprint. It's what you get when a site serves static files, uses minimal JavaScript, has proper HTML structure, and sets the security headers that should be on every production site anyway.</p>
<p>The architecture is described in detail in <a href="/blog/symfony-static-site-generator/">Part 2 of this series</a> (how Symfony generates the static HTML) and <a href="/blog/dev-experience-two-containers/">Part 4</a> (how nginx serves it in production).</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/lighthouse-perfect-score/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[performance]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[accessibility]]></category>
                        <category><![CDATA[seo]]></category>
                    </item>
                <item>
            <title><![CDATA[notACMS 1.1 — Bare core, demo theme, living proof]]></title>
            <link>https://holas.pl/blog/notacms-1-1-bare-core-demo-theme/</link>
            <guid isPermaLink="true">https://holas.pl/blog/notacms-1-1-bare-core-demo-theme/</guid>
                        <pubDate>Mon, 04 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS 1.1.0 landed on April 24, 1.1.1 two days later, and 1.1.2 followed about a week on. Three releases that change not what notACMS does, but how you start with it — and what you get out of the box. notACMS grew out of my own site and for the first release it was one piece: clone the repo, get a full design, start overriding. It worked, but anyone who wanted to build their own look from scratc…]]></description>
            <content:encoded><![CDATA[<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.0">notACMS 1.1.0</a> landed on April 24, <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.1">1.1.1</a> two days later, and <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/releases/tag/1.1.2">1.1.2</a> followed about a week on. Three releases that change not what notACMS does, but how you start with it — and what you get out of the box.</p>
<hr />
<p>notACMS grew out of my own site and for the first release it was one piece: clone the repo, get a full design, start overriding. It worked, but anyone who wanted to build their own look from scratch had to fight against things they didn't need. 1.1.0 fixes this by splitting core from the demo theme.</p>
<h2>The core is a skeleton<a id="the-core-is-a-skeleton" href="#the-core-is-a-skeleton" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>templates/</code>, <code>assets/</code>, <code>translations/</code> are now a minimal wireframe. System fonts, light mode, ~200 lines of CSS. All features work — blog, pages, search, RSS, sitemap, responsive images, contact form — but it looks like a site from the 90s. Deliberately. If you're building your own design, you don't fight a theme that imposes a visual language on you.</p>
<p>The demo theme lives in <code>docs/demo/</code> and is the default seed — <code>./notACMS deploy</code> or <code>ddev build</code> will lay it down on first run. Pass <code>--bare</code> if you want the skeleton instead:</p>
<pre><code class="language-bash">./notACMS deploy           # amber-phosphor (default), ready to tweak
./notACMS deploy --bare    # skeleton, build your own from scratch
</code></pre>
<p>Everything else — the <a href="/local-override-pattern/">local override pattern</a>, no core editing, clean <code>git pull</code> — works the same regardless of your choice.</p>
<h3>What else shipped in 1.1.0<a id="what-else-shipped-in-110" href="#what-else-shipped-in-110" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Reading time and reading progress on posts and docs. Language switcher as a Twig extension. Post excerpts that no longer leak <code>#</code> from heading anchors. PHPUnit test scaffolding under <code>tests/</code>. AI-agent skills for working with the repo. An old-template compatibility package at <code>docs/customization/old-template/</code> — one <code>cp -r</code> and you're back to the 1.0.0 look.</p>
<h2>1.1.1 — the patch that real use forced<a id="111--the-patch-that-real-use-forced" href="#111--the-patch-that-real-use-forced" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Preparing this post on holas.pl, deploying to production, I noticed something annoying: <code>./notACMS deploy --prod</code> backed up and replaced <code>local/</code> on every run. If you already had content there, it was gone. Deploy couldn't tell &quot;user wants to replace the whole theme&quot; from &quot;user just wants to build their site with existing content.&quot;</p>
<p>1.1.1 fixes it so deploy behaves like <code>ddev build</code>: it seeds <code>local/</code> only when the directory is missing or empty. Content you already have stays untouched. Want to force a re-seed? Pass <code>--bare</code> or <code>--demo</code> explicitly.</p>
<p>The other change is frontmatter-driven navigation labels. Until now every tab in the menu needed a translation key in every locale file — <code>nav.home</code>, <code>nav.about</code>, <code>site.releases</code> and so on. Adding a page meant updating N YAML files. Now <code>menu.label</code> in a page's frontmatter is enough:</p>
<pre><code class="language-yaml">---
title: &quot;Architecture guide&quot;
menu:
  label: &quot;Architecture&quot;
  weight: 30
---
</code></pre>
<p>A new <code>content_item()</code> Twig function reads it without extra config:</p>
<pre><code class="language-twig">{{ content_item('architecture-guide', 'en').menuLabel() }}
</code></pre>
<p>Polish, German, and French demo content got a full review across every page and blog post.</p>
<h2>1.1.2 — review-driven hardening<a id="112--review-driven-hardening" href="#112--review-driven-hardening" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>1.1.2 is what happens when you sit down with the code before tagging and look at it with fresh eyes. Two security bugs surfaced during the review and got fixed before release: an open-redirect through path normalisation (<code>Request::getPathInfo()</code> doesn't collapse repeated slashes, so <code>/&lt;default-locale&gt;//evil.com</code> would have produced a <code>Location: //evil.com</code> cross-origin redirect), and an XSS in the search results page where Pagefind's <code>excerpt</code> was injected into innerHTML without escaping.</p>
<p>The headline feature is canonical URLs. If your default locale is <code>en</code>, <code>/en/blog/</code> and <code>/blog/</code> were both reachable and rendered the same content — two indexable URLs for one page. A new event listener now issues <code>301</code>s from <code>/&lt;default-locale&gt;/...</code> to the unprefixed form before Symfony's router even runs.</p>
<p>Internally, the inline <code>|json_encode|raw</code> JSON-LD blocks scattered across templates are gone. A small <code>StructuredDataBuilder</code> service plus two Twig functions (<code>json_ld()</code> and <code>structured_data()</code>) replace them with a fluent, typed API. <code>JSON_THROW_ON_ERROR</code> is on, so a bad UTF-8 byte in frontmatter raises an exception during render instead of silently shipping <code>&lt;script&gt;false&lt;/script&gt;</code>. The bare core templates for <code>contact</code>, <code>default</code>, and <code>projects</code> pages now emit Schema.org markup too — bare deploys no longer have weaker SEO than every customisation example.</p>
<h2>The demo as living proof<a id="the-demo-as-living-proof" href="#the-demo-as-living-proof" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The most satisfying part of this release is not the code — it's what ships in <code>docs/demo/</code>. Not the theme. A complete, four-language site that lives in the repository. It has its own manual, architecture docs, styleguide, and a releases blog — all running, all rendered by the same system you get after <code>git clone</code>.</p>
<ul>
<li><strong><a href="/manual/">Manual</a></strong> — install, config, content structure, frontmatter, build commands, deploy, environment variables, troubleshooting</li>
<li><strong><a href="/architecture/">Architecture</a></strong> — routing, content pipeline, static build, search, multi-language, deployment</li>
<li><strong><a href="/styleguide/">Styleguide</a></strong> — every component documented with real SCSS tokens</li>
<li><strong><a href="/blog/releases/">Releases blog</a></strong> — posts about every version, rendered by the system itself</li>
</ul>
<p>&quot;I trust you that it works, but show me&quot; — this is that. The demo is not an example, it's proof. Multi-language routing, static pre-rendering, Pagefind search, responsive images, contact form, RSS, sitemap — everything runs in the demo content. Someone doing a fresh <code>git clone</code> followed by <code>ddev build</code> sees exactly this site.</p>
<h2>Links<a id="links" href="#links" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Full changelog with every change categorised: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/CHANGELOG.md#112---2026-05-04">CHANGELOG.md</a>.</p>
<p>Breaking changes and migration from 1.0.0: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS/blob/main/UPGRADE-1.1.md">UPGRADE-1.1.md</a>.</p>
<p>Repository: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / holas1337/notACMS</a> — Apache 2.0.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/notacms-1-1/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Building holas.pl with AI — Claude Code, MCP, and Local Image Generation]]></title>
            <link>https://holas.pl/blog/building-with-ai-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/blog/building-with-ai-claude-code/</guid>
                        <pubDate>Sat, 02 May 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 5 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 4 covers the developer experience and deployment. Building holas.pl involved writing a fair amount of PHP, Twig, and SCSS — and making architectural decisions that would be annoying to undo later. I used Claude Code as an AI pair programmer throughout. This post covers what that wo…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 5 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/dev-experience-two-containers/">Part 4</a> covers the developer experience and deployment.</em></p>
<hr />
<p>Building holas.pl involved writing a fair amount of PHP, Twig, and SCSS — and making architectural decisions that would be annoying to undo later. I used <a rel="nofollow noopener noreferrer" target="_blank" href="https://claude.ai/code">Claude Code</a> as an AI pair programmer throughout. This post covers what that workflow actually looks like, where it works well, and where it still needs human judgment.</p>
<h2>AGENTS.md — The AI's Instruction Manual<a id="agentsmd--the-ais-instruction-manual" href="#agentsmd--the-ais-instruction-manual" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The first practical insight from this project: an AI assistant is only as good as the instructions it's given. Without explicit guidance, Claude defaults to generating functional but generic code — reasonable choices, but not necessarily the choices you'd make yourself.</p>
<p>The solution is <code>AGENTS.md</code>, a file in the project root that Claude Code reads at the start of every session. It documents:</p>
<ul>
<li><strong>Architecture rules</strong> — final classes only, no inheritance, interface segregation, value objects over associative arrays</li>
<li><strong>Code style</strong> — strict types on every file, Yoda conditions, blank line before return, PSR-4 namespace convention</li>
<li><strong>Naming conventions</strong> — interface naming (<code>ContentServiceInterface</code> → <code>ContentService</code>), readonly value objects, constructor property promotion</li>
<li><strong>DDEV commands</strong> — how to start the environment, run builds, check code quality</li>
<li><strong>Content structure</strong> — where Markdown files live, how frontmatter works, what the URL slug conventions are</li>
</ul>
<p>With this context, Claude generates code that follows the project's actual conventions. Reviewing a generated class feels like reviewing a pull request from a teammate who has read the style guide — not reviewing output that needs to be translated into project conventions.</p>
<h2>EDITOR_GUIDE.md — Delegating Content Creation<a id="editorguidemd--delegating-content-creation" href="#editorguidemd--delegating-content-creation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The same principle applies to content. <code>EDITOR_GUIDE.md</code> documents:</p>
<ul>
<li>Title format and length (under 70 characters, technology names included, no clickbait)</li>
<li>Description format (120–160 characters, no &quot;In this post...&quot;, lead with reader benefit)</li>
<li>Intro structure (2–3 sentences, no greeting, problem first)</li>
<li>Body conventions (code blocks for all commands, short paragraphs, numbered steps for procedures)</li>
<li>EN/PL parity requirements (both versions equal depth, same code blocks)</li>
<li>Frontmatter fields and their formats</li>
</ul>
<p>With this guide, the content creation workflow becomes:</p>
<ol>
<li>Write the post in rough form — the ideas, the code snippets, the structure</li>
<li>Hand it to Claude with: &quot;proofread this, correct the English, translate to Polish, and produce the two <code>.md</code> files with correct frontmatter&quot;</li>
<li>Review the result</li>
</ol>
<p>The guide is specific enough that Claude doesn't need to ask clarifying questions. Title format, slug convention, category values, tag format, image path pattern — it's all documented. The output is ready to commit.</p>
<h2>Image Generation with Draw Things and MCP<a id="image-generation-with-draw-things-and-mcp" href="#image-generation-with-draw-things-and-mcp" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Every post needs a featured image (minimum 1200×630px). For holas.pl, images are generated locally using <a rel="nofollow noopener noreferrer" target="_blank" href="https://drawthings.ai/">Draw Things</a> via the MCP (Model Context Protocol) integration in Claude Code.</p>
<p>The workflow:</p>
<ol>
<li>Claude Code calls <code>mcp__draw-things__generate_image</code> with a prompt describing the image, 4 parallel variants at 1024×576px with <code>steps=4</code></li>
<li>The best variant is selected from <code>.generated/YYYY-MM-DD-session-name/</code></li>
<li>ImageMagick upscales it to production size:</li>
</ol>
<pre><code class="language-bash">ddev exec convert .generated/session/image.jpg \
    -resize 1920x1080! -filter Lanczos -quality 92 \
    assets/images/output.jpg
</code></pre>
<ol start="4">
<li>The image is moved to <code>content/blog/category/post-name/files/</code> and referenced in frontmatter</li>
</ol>
<p>The <code>.generated/</code> directory is gitignored — it holds expendable previews. Only approved images committed to <code>files/</code> become production assets.</p>
<p>MCP (Model Context Protocol) is what makes this work: it's a standard interface for connecting AI assistants to external tools. Claude Code connects to Draw Things running locally, Hugging Face Spaces, and other services without leaving the development session. The image generation happens on the local machine — no API quota, no external service, no per-image cost.</p>
<h2>What AI Does Well<a id="what-ai-does-well" href="#what-ai-does-well" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Boilerplate and patterns</strong> — generating a new service with its interface, value object, and correct import ordering is instant. The structure is consistent with the rest of the codebase because the conventions are documented.</p>
<p><strong>SCSS from description</strong> — describing a component layout in words and getting working SCSS back, with the project's variable names, is faster than writing it from scratch.</p>
<p><strong>Translation</strong> — Polish and English content at equal quality. The editorial guide's specificity about what &quot;equal quality&quot; means (same code blocks, same depth, not a summary) produces translations that don't need significant editing.</p>
<p><strong>Repetitive structured work</strong> — generating nginx redirect lists, updating frontmatter across multiple files, writing sitemap entries — tasks with clear rules but many instances.</p>
<p><strong>Staying in context</strong> — Claude Code reads the project files, understands the existing patterns, and generates code that fits without being told what every class does.</p>
<h2>Where Human Judgment Is Still Required<a id="where-human-judgment-is-still-required" href="#where-human-judgment-is-still-required" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><strong>Architecture decisions</strong> — which abstractions to introduce, when a value object is warranted, how to structure the content pipeline — these require understanding the trade-offs in a way that goes beyond pattern matching. The AI generates plausible options; the decision is still human.</p>
<p><strong>Design aesthetics</strong> — SCSS variables can be generated, but deciding whether the color palette looks right on a dark terminal-style background requires eyes and taste.</p>
<p><strong>Content voice</strong> — the editorial guide captures tone conventions, but the actual ideas — what's worth writing about, what angle is interesting — come from experience, not from a prompt.</p>
<p><strong>Reviewing AI output</strong> — the code and content still need to be read. AI-generated code is plausible-looking; it takes a developer to notice when something is technically correct but architecturally wrong.</p>
<h2>The Practical Result<a id="the-practical-result" href="#the-practical-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire site — architecture, frontend, content pipeline, contact form, deployment scripts, this blog post series — was built with Claude Code. The time investment in <code>AGENTS.md</code> and <code>EDITOR_GUIDE.md</code> paid back immediately: less time correcting style, less time explaining the same conventions session after session, more time on the actual work.</p>
<p>The most surprising benefit was content. Writing a post in rough Polish, having it proofread, translated to English, and converted to two properly-structured <code>.md</code> files with correct frontmatter in one step — that removes enough friction that publishing actually happens.</p>
<p>The architecture built this way doesn't just produce maintainable code — it produces measurable results. The <a href="/blog/lighthouse-perfect-score/">next post</a> looks at the Lighthouse scores: what drives each of the four metrics and why most of it follows from the architecture, not from optimisation work.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/building-with-ai-claude-code/building-with-ai-claude-code.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                    </item>
                <item>
            <title><![CDATA[Developer Experience — From Local Dev to Production in Two Containers]]></title>
            <link>https://holas.pl/blog/dev-experience-two-containers/</link>
            <guid isPermaLink="true">https://holas.pl/blog/dev-experience-two-containers/</guid>
                        <pubDate>Mon, 27 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 4 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 3 covers the contact form security. One of the goals for holas.pl was a development environment as simple as the production one. No database to start, no Docker networking to configure by hand, no five-minute startup sequence. The result is a DDEV-based dev setup that starts with a…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 4 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/contact-form-security/">Part 3</a> covers the contact form security.</em></p>
<hr />
<p>One of the goals for holas.pl was a development environment as simple as the production one. No database to start, no Docker networking to configure by hand, no five-minute startup sequence. The result is a DDEV-based dev setup that starts with a single command and a production stack that runs in two containers.</p>
<h2>Development with DDEV<a id="development-with-ddev" href="#development-with-ddev" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://ddev.readthedocs.io/">DDEV</a> handles the local development environment. The entire setup is in <code>.ddev/config.yaml</code>:</p>
<pre><code class="language-yaml">name: holas-pl
type: php
php_version: &quot;8.5&quot;
webserver_type: nginx-fpm
nodejs_version: &quot;22&quot;
omit_containers: [db]
web_environment:
  - APP_ENV=dev
</code></pre>
<p>The <code>omit_containers: [db]</code> line is notable — there's no database, so there's no database container. DDEV's default MySQL/MariaDB setup is skipped entirely. <code>ddev start</code> spins up nginx + PHP-FPM and nothing else.</p>
<p>In development the content tree is rebuilt on every request, so editing a Markdown file and refreshing the browser shows the change immediately. No cache to clear. Symfony's debug toolbar is available. Draft posts are visible.</p>
<h2>The Build Command<a id="the-build-command" href="#the-build-command" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev build</code> runs the full pipeline:</p>
<pre><code class="language-bash">rm -rf var/dart-sass         # remove arch-specific binary (see below)
php bin/console cache:clear
php bin/console sass:build   # compile SCSS via dart-sass
php bin/console asset-map:compile  # fingerprint assets
php bin/console app:build    # render all URLs to public/static/
npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>After this, <code>public/static/</code> contains the complete site as HTML files. nginx serves from that directory. The dart-sass step removes the platform-specific binary before the build to force a fresh download — more on this below.</p>
<h2>Code Quality Checks<a id="code-quality-checks" href="#code-quality-checks" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><code>ddev code-check</code> runs four checks in sequence:</p>
<pre><code class="language-bash">composer validate --strict
composer audit --no-dev         # checks for known CVEs in dependencies
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/phpstan analyse      # level 6
</code></pre>
<p><code>ddev code-fix</code> auto-fixes PHP CS Fixer issues and re-runs the check. PHPStan level 6 catches missing type hints, wrong argument types, and unknown methods before they reach production.</p>
<h2>The Styleguide<a id="the-styleguide" href="#the-styleguide" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A dev-only page at <code>/styleguide/</code> documents every component in the UI using the actual CSS classes — not wrappers or snapshots. The page is served only in the <code>dev</code> environment (the controller throws a 404 in production) and is not included in the static build.</p>
<p>The value of the styleguide is in development: changing a component's SCSS immediately updates the styleguide. There's no separate design system to keep in sync.</p>
<h2>Asset Pipeline Without Node.js<a id="asset-pipeline-without-nodejs" href="#asset-pipeline-without-nodejs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>SCSS is compiled by <code>symfonycasts/sass-bundle</code>, which wraps dart-sass and requires no Node.js installation. The single entrypoint <code>assets/styles/app.scss</code> imports all partials. In development it compiles on-the-fly. In production <code>php bin/console sass:build</code> runs before the build.</p>
<p>JavaScript modules are handled by Symfony's AssetMapper — no webpack, no Vite, no rollup. Scripts are loaded as plain <code>&lt;script src=&quot;...&quot;&gt;</code> tags with content-hashed filenames. The import map registers only the main <code>app.js</code> entrypoint; other scripts (contact form, search, cookie banner) are loaded separately via <code>{{ asset('script.js') }}</code> in templates.</p>
<p><strong>The dart-sass binary problem:</strong> <code>symfonycasts/sass-bundle</code> downloads a platform-specific dart-sass binary to <code>var/dart-sass/</code>. The binary compiled on a development machine (x86_64) won't run in the production Docker container (also x86_64 in this case, but the binary path and version can differ). The solution is straightforward: delete the binary before every build and let dart-sass download the correct one for the current platform.</p>
<h2>Production: Two Containers<a id="production-two-containers" href="#production-two-containers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The production <code>docker-compose.yaml</code>:</p>
<pre><code class="language-yaml">services:
  nginx:
    image: nginx:alpine
    volumes:
      - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - .:/app:ro
    depends_on:
      - php

  php:
    build:
      context: .
      dockerfile: docker/Dockerfile
    user: &quot;${UID:-1000}:${GID:-1000}&quot;
    volumes:
      - .:/app
</code></pre>
<p>Two containers: nginx and PHP-FPM. No database container. nginx mounts the project read-only; PHP-FPM runs as the host user to avoid file permission issues with the Symfony cache.</p>
<p>The PHP image (<code>docker/Dockerfile</code>) is <code>php:8.5-fpm-alpine</code> with only what's needed: <code>icu-dev</code> (Symfony intl), <code>nodejs</code> and <code>npm</code> (for the Pagefind build step), <code>unzip</code>, <code>git</code>, and Composer.</p>
<h2>Deployment<a id="deployment" href="#deployment" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire deployment is a single script <code>./deploy.sh --prod</code>:</p>
<ol>
<li><code>docker compose down</code></li>
<li><code>docker compose build --pull</code> — rebuilds the PHP image from scratch</li>
<li><code>docker compose up -d</code></li>
<li><code>composer install --no-dev --optimize-autoloader</code></li>
<li><code>php bin/console cache:clear</code></li>
<li><code>rm -rf var/dart-sass</code> — remove the arch-specific binary</li>
<li><code>php bin/console sass:build</code></li>
<li><code>php bin/console asset-map:compile</code></li>
<li><code>php bin/console app:build</code> — render all static HTML</li>
<li><code>npx --yes pagefind --site public/static --output-path public/pagefind</code></li>
</ol>
<p>The build runs inside the PHP container where the correct CPU architecture is known. After step 10, nginx is already serving the previous build's static files. The new files replace them atomically at the filesystem level. There's a brief window where a partial build is live, but for a low-traffic portfolio site this is acceptable without blue-green deployment complexity.</p>
<p>No CI/CD pipeline, no staging environment. Deployment is <code>ssh server</code>, <code>cd holas.pl</code>, <code>git pull</code>, <code>./deploy.sh --prod</code>.</p>
<h2>Compared to WordPress<a id="compared-to-wordpress" href="#compared-to-wordpress" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The full WordPress production stack required: PHP-FPM, MySQL, a caching layer (Redis or filesystem), a scheduled task runner for wp-cron, and enough RAM to keep MySQL warm. Updates to any component required downtime or careful sequencing.</p>
<p>The current stack is two containers. A Raspberry Pi 5 runs it without memory pressure. Deployment is a shell script. The entire codebase, content, and configuration fits in a single git repository. Backup is <code>git push</code>.</p>
<p>The <a href="/blog/building-with-ai-claude-code/">next post in the series</a> covers the most unconventional part of this project: building the entire site with Claude Code as an AI pair programmer, and using MCP tools to generate featured images locally.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/dev-experience-two-containers/dev-experience-two-containers.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[ddev]]></category>
                        <category><![CDATA[raspberry-pi]]></category>
                        <category><![CDATA[homelab]]></category>
                    </item>
                <item>
            <title><![CDATA[Building a Tool Decision Tree for Claude Code with Global Memory]]></title>
            <link>https://holas.pl/blog/building-tool-decision-tree-claude-code/</link>
            <guid isPermaLink="true">https://holas.pl/blog/building-tool-decision-tree-claude-code/</guid>
                        <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[Claude Code can connect to external tools through MCP (Model Context Protocol) servers — Sentry for production errors, JetBrains for IDE introspection, Context7 for library docs, Perplexity for web search. The problem: with six MCP servers available, Claude doesn&#039;t always pick the right one. It might grep through 20 files to find a Symfony route when JetBrains can return it in one call, or query C…]]></description>
            <content:encoded><![CDATA[<p>Claude Code can connect to external tools through MCP (Model Context Protocol) servers — Sentry for production errors, JetBrains for IDE introspection, Context7 for library docs, Perplexity for web search. The problem: with six MCP servers available, Claude doesn't always pick the right one. It might grep through 20 files to find a Symfony route when JetBrains can return it in one call, or query Context7 for &quot;latest PHP version&quot; when only Perplexity has current data.</p>
<p>The fix is a global <code>CLAUDE.md</code> — a persistent instruction file that teaches Claude which tool to reach for based on the query type. This post walks through my setup, the decision tree I built, and how you can build one for your own stack.</p>
<h2>The Problem: Too Many Tools, No Strategy<a id="the-problem-too-many-tools-no-strategy" href="#the-problem-too-many-tools-no-strategy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>MCP servers give Claude Code superpowers: browser automation, error tracking, documentation lookup, IDE integration. But more tools means more choices, and without guidance Claude makes reasonable but suboptimal picks.</p>
<p>Common failure modes I observed:</p>
<ul>
<li><strong>Wrong tool for the job</strong> — querying Context7 for &quot;latest Symfony version&quot; (it only has docs, not release metadata) instead of Perplexity</li>
<li><strong>Expensive path when a cheap one exists</strong> — using Grep to search for Symfony routes across multiple files instead of asking JetBrains MCP for a structured route list</li>
<li><strong>Missing the specialist</strong> — not checking Sentry for a production error when the stack trace would immediately reveal the root cause</li>
<li><strong>Redundant queries</strong> — trying multiple tools sequentially when the memory could route to the right one immediately</li>
</ul>
<p>The solution is explicit routing rules stored in Claude Code's global <code>CLAUDE.md</code>. Claude reads this file at the start of every session, so the decision tree is always available.</p>
<h2>My MCP Stack<a id="my-mcp-stack" href="#my-mcp-stack" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Here are the six MCP servers I run and what each does best.</p>
<h3>Context7 — Library and Framework Docs<a id="context7--library-and-framework-docs" href="#context7--library-and-framework-docs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Context7 serves versioned documentation with code examples. You give it a library name and a question, and it returns the relevant section from official docs.</p>
<p><strong>Workflow:</strong> <code>resolve-library-id</code> (find the library) → <code>query-docs</code> (fetch docs for a specific question). It supports versioned lookups — I can query <code>/sylius/sylius/v1.14.6</code> specifically, not just &quot;latest.&quot;</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Method signatures and configuration examples</li>
<li>Migration guides between framework versions</li>
<li>How-to patterns from official docs (Symfony forms, Doctrine mappings, API Platform filters)</li>
</ul>
<p><strong>Fails for:</strong></p>
<ul>
<li>Current version numbers or release dates (it has docs, not metadata)</li>
<li>PHP language features (PHP itself isn't a library with versioned docs in Context7)</li>
<li>Security CVEs or advisories</li>
<li>Anything that requires real-time information</li>
</ul>
<h3>Perplexity — Current Facts and Research<a id="perplexity--current-facts-and-research" href="#perplexity--current-facts-and-research" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Perplexity is an AI-powered web search with four modes: <code>search</code> (web results with citations), <code>ask</code> (AI-synthesized answers via sonar-pro), <code>research</code> (deep multi-source analysis, 30+ seconds), and <code>reason</code> (step-by-step logical reasoning).</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Latest version numbers, release dates, EOL schedules</li>
<li>Security advisories and CVE details</li>
<li>PHP language features and RFCs (property hooks, asymmetric visibility — these aren't in Context7)</li>
<li>External service pricing (API costs, hosting comparisons)</li>
<li>General programming best practices and benchmarks</li>
</ul>
<p><strong>Key role:</strong> Perplexity fills every gap Context7 has. When Context7 returns nothing or returns stale docs, Perplexity almost always has the answer.</p>
<h3>JetBrains — IDE-Level Code Intelligence<a id="jetbrains--ide-level-code-intelligence" href="#jetbrains--ide-level-code-intelligence" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>This is the MCP server that saves the most tokens. JetBrains MCP connects Claude Code to your IDE's index — the same index that powers autocomplete, go-to-definition, and refactoring. The base JetBrains MCP provides generic tools (file search, symbol lookup, text search, terminal commands), and framework plugins extend it with specialized tools — the Symfony plugin adds route listing, service lookup, Doctrine entity inspection, and Twig analysis.</p>
<p><strong>My typical workflow:</strong> I paste a Jira ticket link into the conversation. Claude reads the task via the Atlassian MCP, then uses JetBrains MCP to do a quick code reconnaissance — finding the relevant services, checking route definitions, inspecting entity fields — all before writing a single line of code. This &quot;analyze first&quot; step catches misunderstandings early and gives Claude the context to ask better clarifying questions.</p>
<p><strong>Symfony-specific capabilities</strong> (via the Symfony plugin):</p>
<ul>
<li><code>list_symfony_routes_controllers</code> — all routes with controller, path, methods. One call instead of grepping through attributes across dozens of files</li>
<li><code>locate_symfony_service</code> — find any service definition by its fully-qualified class name</li>
<li><code>list_doctrine_entity_fields</code> — entity fields, types, and relationships in structured format</li>
<li><code>list_symfony_commands</code>, <code>list_symfony_forms</code> — console commands and form types at a glance</li>
</ul>
<p><strong>Token savings example:</strong> Finding all routes matching <code>/api/</code> in a Symfony project:</p>
<ul>
<li><strong>Without JetBrains:</strong> Grep for <code>#[Route</code> across <code>src/Controller/</code>, read each matching file, parse the route attributes, cross-reference with <code>_routes.yaml</code>. Easily 5-10 tool calls and thousands of tokens of file content.</li>
<li><strong>With JetBrains:</strong> One <code>list_symfony_routes_controllers</code> call returns a structured, filterable list. Done.</li>
</ul>
<p><strong>Also useful for:</strong> Indexed text search (<code>search_in_files_by_text</code>), file search by name, symbol lookup, running terminal commands in the IDE, build/test execution.</p>
<h3>Chrome DevTools — Browser Automation<a id="chrome-devtools--browser-automation" href="#chrome-devtools--browser-automation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Chrome DevTools MCP lets Claude control a browser: navigate to URLs, click elements, fill forms, take screenshots, inspect network requests, run JavaScript, and execute Lighthouse audits.</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Testing UI changes visually after modifying templates or CSS</li>
<li>Running Lighthouse performance/accessibility audits</li>
<li>Debugging frontend issues (checking console errors, network requests)</li>
<li>Verifying responsive behavior at different viewport sizes</li>
</ul>
<h3>Sentry — Production Error Tracking<a id="sentry--production-error-tracking" href="#sentry--production-error-tracking" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Sentry MCP connects Claude to your error tracking system. It can search issues, retrieve stack traces, analyze errors with Sentry's AI (Seer), and look up release and deployment information.</p>
<p><strong>The workflow that makes this valuable:</strong></p>
<ol>
<li>You notice an error (or a user reports one)</li>
<li>Claude queries Sentry: &quot;search for 500 errors in the last 24 hours&quot;</li>
<li>Sentry returns the stack trace, affected users count, first/last seen timestamps</li>
<li>Claude reads the relevant source file, identifies the root cause, and proposes a fix</li>
<li>The entire debug cycle happens without leaving the terminal</li>
</ol>
<p><strong>Best for:</strong></p>
<ul>
<li>Investigating production errors with full stack traces</li>
<li>Understanding error frequency and patterns (is it new? is it getting worse?)</li>
<li>Correlating errors with recent deployments</li>
</ul>
<h3>Atlassian/Jira — Task Management<a id="atlassianjira--task-management" href="#atlassianjira--task-management" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Jira MCP provides full issue lifecycle management: create, read, edit, transition, comment, and search with JQL.</p>
<p><strong>Best for:</strong></p>
<ul>
<li>Reading task specifications before starting work</li>
<li>Updating issue status as work progresses</li>
<li>Adding technical comments to issues for team visibility</li>
<li>JQL searches to find related issues or check what's in the current sprint</li>
</ul>
<h2>The Decision Tree<a id="the-decision-tree" href="#the-decision-tree" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The core of the global <code>CLAUDE.md</code> is a routing table that maps query types to the best tool. Here's the actual content from mine:</p>
<pre><code class="language-markdown">## Search &amp; Research — Tool Decision Tree

### When to use Context7
**Best for**: library/framework API docs with clean code examples
- Versioned library docs (Sylius, Doctrine, API Platform, Symfony, GitHub Actions)
- Official method signatures, configuration examples, how-to patterns
- Concise, authoritative answers directly from official source
- `resolve-library-id` first, then `query-docs`
- **Fails for**: current version/release info, PHP language features,
  security CVEs, pricing, general programming

### When to use Perplexity
**Best for**: anything current, factual, or not a library doc
- Latest versions, release dates, EOL schedules
- Security advisories, CVEs, vulnerability details
- PHP language features (property hooks, new syntax)
- External service pricing
- General programming best practices, benchmarks
- Supplement when Context7 fails or for real-world context

### When to use WebSearch
- Official blog posts / release announcements
- As last resort or to supplement
</code></pre>
<p>The key pattern: each section leads with &quot;best for&quot; (when to pick this tool) and ends with &quot;fails for&quot; (when to skip it). Claude uses both signals — positive routing and negative routing.</p>
<h3>The Benchmark Table<a id="the-benchmark-table" href="#the-benchmark-table" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>I ran the same 10 query types through Context7, Perplexity, and WebSearch and rated the results. This table lives in the global <code>CLAUDE.md</code> so Claude can reference it when deciding:</p>
<table>
<thead>
<tr>
<th>Query type</th>
<th>Context7</th>
<th>Perplexity</th>
<th>WebSearch</th>
</tr>
</thead>
<tbody>
<tr>
<td>Versioned library docs</td>
<td>★★★★★</td>
<td>★★★★</td>
<td>★★★</td>
</tr>
<tr>
<td>Current version/release info</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Code examples from official docs</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>PHP language features</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Framework how-to (API Platform etc.)</td>
<td>★★★★★</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Security CVEs / advisories</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>General programming (benchmarks etc.)</td>
<td>✗</td>
<td>★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>CI/DevOps workflows</td>
<td>★★★★</td>
<td>★★★★</td>
<td>★★★★</td>
</tr>
<tr>
<td>Release notes / new features</td>
<td>★★★★</td>
<td>★★★</td>
<td>★★★★★</td>
</tr>
<tr>
<td>External service pricing</td>
<td>✗</td>
<td>★★★★★</td>
<td>★★★★★</td>
</tr>
</tbody>
</table>
<p>The pattern is clear: Context7 is excellent for versioned docs but scores zero on anything requiring current or real-world data. Perplexity covers nearly everything. WebSearch is the strongest for blog posts and release announcements.</p>
<p>Including this table in the global <code>CLAUDE.md</code> gives Claude a quantitative basis for tool selection, not just rules.</p>
<h2>How Global Memory Works<a id="how-global-memory-works" href="#how-global-memory-works" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Claude Code supports a global instruction file at <code>~/.claude/CLAUDE.md</code>. This file is loaded at the start of every conversation, regardless of which project you're working in. It's the right place for tool routing rules because MCP servers are configured globally, not per-project.</p>
<p>Compare this with project-level <code>AGENTS.md</code>:</p>
<table>
<thead>
<tr>
<th></th>
<th><code>AGENTS.md</code></th>
<th><code>~/.claude/CLAUDE.md</code></th>
</tr>
</thead>
<tbody>
<tr>
<td>Scope</td>
<td>One project</td>
<td>All projects</td>
</tr>
<tr>
<td>Content</td>
<td>Code conventions, architecture, commands</td>
<td>Tool routing, personal preferences</td>
</tr>
<tr>
<td>Example</td>
<td>&quot;Use <code>ddev exec</code> for all PHP commands&quot;</td>
<td>&quot;Use Context7 for Symfony docs&quot;</td>
</tr>
</tbody>
</table>
<h3>Structuring the Global CLAUDE.md<a id="structuring-the-global-claudemd" href="#structuring-the-global-claudemd" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>The global <code>CLAUDE.md</code> works best when it's structured like a reference manual, not a narrative. Claude scans it at session start — clear headings and explicit rules make that scan effective.</p>
<p><strong>Tips from my experience:</strong></p>
<ol>
<li><strong>Lead with the decision rule, not the description.</strong> &quot;Best for: versioned library docs&quot; is more useful than &quot;Context7 is a documentation server that...&quot;</li>
<li><strong>Include failure modes.</strong> &quot;Fails for: current versions&quot; prevents Claude from trying Context7 for queries it can't handle.</li>
<li><strong>Add a server inventory.</strong> List every MCP server with its tools — Claude can reference this when it needs a capability it hasn't used before.</li>
<li><strong>Use concrete examples.</strong> &quot;Latest Symfony version → Perplexity&quot; beats &quot;use Perplexity for current data.&quot;</li>
<li><strong>Update when tools change.</strong> Added a new MCP server? Update the file. Removed one? Remove its entry. Stale routing rules are worse than no rules.</li>
</ol>
<h2>Build Your Own<a id="build-your-own" href="#build-your-own" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The specific MCP servers don't matter — the pattern does. Whether you use Cursor, Windsurf, or Claude Code, whether you write Python or Go, the principle is the same: teach your AI assistant which tool to reach for.</p>
<p><strong>Step-by-step:</strong></p>
<ol>
<li><strong>List your MCP servers</strong> (or equivalent tool integrations) and what each one does</li>
<li><strong>Identify overlaps</strong> — where can two tools answer the same query? (Context7 and Perplexity both handle Symfony docs, but with different strengths)</li>
<li><strong>Benchmark</strong> — run the same 5-10 representative queries through each overlapping tool. Rate the results. This gives you data, not gut feeling.</li>
<li><strong>Write routing rules</strong> — for each tool, write &quot;best for&quot; and &quot;fails for&quot; sections with specific query types</li>
<li><strong>Include the benchmark</strong> — the table gives your AI a quantitative reference, not just instructions</li>
<li><strong>Iterate</strong> — the first version won't be perfect. When Claude picks the wrong tool, update the file. Over a few sessions, the routing gets tight.</li>
</ol>
<p>My global <code>CLAUDE.md</code> started as a list of MCP servers with one-line descriptions. After a few weeks of observing where Claude made suboptimal choices, it evolved into the decision tree above. The benchmark table was the biggest single improvement — it turned vague &quot;prefer X over Y&quot; rules into concrete data Claude could act on.</p>
<p>The investment is small (an hour to set up, a few minutes per update) and the payoff compounds: fewer wasted tokens, faster answers, and less time correcting tool choices mid-conversation.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/claude-code-mcp-memory/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[ai]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[dev-tools]]></category>
                    </item>
                <item>
            <title><![CDATA[Securing a Static Site&#039;s Only Dynamic Endpoint — The Contact Form]]></title>
            <link>https://holas.pl/blog/contact-form-security/</link>
            <guid isPermaLink="true">https://holas.pl/blog/contact-form-security/</guid>
                        <pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 3 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 2 covers the architecture. holas.pl is a static site with one exception: the contact form. Every blog post, category page, and static page is a pre-rendered HTML file served by nginx. The contact form is the only endpoint that runs PHP. That single exception raises a specific secur…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 3 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/symfony-static-site-generator/">Part 2</a> covers the architecture.</em></p>
<hr />
<p>holas.pl is a static site with one exception: the contact form. Every blog post, category page, and static page is a pre-rendered HTML file served by nginx. The contact form is the only endpoint that runs PHP.</p>
<p>That single exception raises a specific security question: how do you protect a form on a static page from bot submissions and CSRF attacks when there's no session?</p>
<h2>The CSRF Problem with Static Pages<a id="the-csrf-problem-with-static-pages" href="#the-csrf-problem-with-static-pages" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Standard CSRF protection works by embedding a token in the form that is tied to the user's session. When the form is submitted, the server verifies the token matches the one in the session.</p>
<p>Static pages don't have sessions. The contact page is generated once during <code>ddev build</code> and served as a file. It can't generate a per-user token at render time. Symfony's built-in <code>csrf_protection</code> is therefore explicitly disabled on the contact form:</p>
<pre><code class="language-php">public function configureOptions(OptionsResolver $resolver): void
{
    $resolver-&gt;setDefaults([
        'csrf_protection' =&gt; false,  // deliberate — static page, no session
    ]);
}
</code></pre>
<p>Disabling CSRF protection is the right call here. The alternative — adding a dynamic PHP endpoint just to generate tokens for the form — would reintroduce the PHP-on-every-request problem for a page that otherwise needs none.</p>
<h2>Cloudflare Turnstile as the Replacement<a id="cloudflare-turnstile-as-the-replacement" href="#cloudflare-turnstile-as-the-replacement" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://developers.cloudflare.com/turnstile/">Cloudflare Turnstile</a> is a CAPTCHA alternative that runs client-side, confirms the user is human, and provides a one-time token that can be verified server-side. It replaces CSRF as the bot-prevention layer.</p>
<p>The form flow:</p>
<ol>
<li>The contact page is served as static HTML with the Turnstile widget embedded (site key baked in at build time)</li>
<li>Turnstile runs its challenge invisibly; on success it calls <code>window.onTurnstileSuccess(token)</code>, which writes the token into a hidden field</li>
<li><code>contact.js</code> intercepts the form <code>submit</code> event and sends the data via <code>fetch()</code> instead of a standard form post</li>
<li>The PHP endpoint receives the submission, validates the form fields, then verifies the Turnstile token with Cloudflare's API</li>
<li>Only if both pass does the email get sent</li>
</ol>
<p>Server-side verification is the critical step:</p>
<pre><code class="language-php">final class TurnstileValidator
{
    public function verify(string $token, string $remoteIp): bool
    {
        try {
            $response = $this-&gt;httpClient-&gt;request('POST', 'https://challenges.cloudflare.com/turnstile/v0/siteverify', [
                'body' =&gt; [
                    'secret'   =&gt; $this-&gt;secretKey,
                    'response' =&gt; $token,
                    'remoteip' =&gt; $remoteIp,
                ],
            ]);

            $data = $response-&gt;toArray();

            return true === ($data['success'] ?? false);
        } catch (\Throwable $e) {
            $this-&gt;logger-&gt;error('Turnstile verification failed: '.$e-&gt;getMessage());

            return false;
        }
    }
}
</code></pre>
<p>The validator fails closed — any exception returns <code>false</code> and blocks the submission. An empty or missing token also returns <code>false</code> immediately.</p>
<h2>Minimal PHP Attack Surface<a id="minimal-php-attack-surface" href="#minimal-php-attack-surface" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The entire application's PHP surface in production is two URL patterns:</p>
<pre><code class="language-nginx">location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
    fastcgi_read_timeout 30;
}
</code></pre>
<p>Everything else — every blog post, every category page, the sitemap, the RSS feed, the search page — is handled by nginx serving static files. PHP-FPM is never invoked for content delivery. The attack surface for the PHP layer is a single POST endpoint.</p>
<h2>Content Security Policy<a id="content-security-policy" href="#content-security-policy" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The nginx config applies a strict CSP on every response:</p>
<pre><code class="language-nginx">add_header Content-Security-Policy
    &quot;default-src 'self';
     script-src  'self' challenges.cloudflare.com;
     style-src   'self' 'unsafe-inline';
     img-src     'self' data:;
     frame-src   challenges.cloudflare.com;
     connect-src 'self' challenges.cloudflare.com;&quot;
    always;
</code></pre>
<p>The only external domain permitted is <code>challenges.cloudflare.com</code>, which Turnstile requires for its script, iframe, and API call. No Google Analytics, no CDN scripts, no Facebook pixel. <code>script-src</code> has no <code>'unsafe-inline'</code> and no <code>'unsafe-eval'</code> — all JavaScript is loaded from hashed files via Symfony AssetMapper.</p>
<p>The <code>'unsafe-inline'</code> in <code>style-src</code> is a deliberate compromise: Turnstile's widget injects inline styles that can't be avoided without a CSP nonce, and AssetMapper doesn't currently inject nonces. Everything else is locked down.</p>
<h2>Additional Security Headers<a id="additional-security-headers" href="#additional-security-headers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-nginx">add_header X-Frame-Options        &quot;SAMEORIGIN&quot;                       always;
add_header X-Content-Type-Options &quot;nosniff&quot;                          always;
add_header Referrer-Policy        &quot;strict-origin-when-cross-origin&quot;  always;
add_header Permissions-Policy     &quot;camera=(), microphone=(), geolocation=()&quot; always;
</code></pre>
<p>Hidden files are denied:</p>
<pre><code class="language-nginx">location ~ /\. { deny all; }
</code></pre>
<p>HSTS is handled upstream at Cloudflare, so there's no <code>Strict-Transport-Security</code> header in the nginx config — it would be redundant.</p>
<h2>The Result<a id="the-result" href="#the-result" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The contact form works without sessions, without CSRF tokens, and without JavaScript being required for anything other than the form submission itself. The page renders fully as static HTML. Bot submissions are blocked by Turnstile's server-side verification. The PHP endpoint is unreachable for anything other than POST requests to <code>/api/contact</code>.</p>
<p>Compared to WordPress with a contact form plugin, the attack surface went from &quot;PHP running on every request, wp-admin exposed, XML-RPC enabled, 22 plugins any of which could have a vulnerability&quot; to &quot;one POST endpoint with Cloudflare verification.&quot;</p>
<p>The <a href="/blog/dev-experience-two-containers/">next post</a> covers the developer experience — DDEV, the build pipeline, and deployment to production in two Docker containers.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/contact-form-security/contact-form-security.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[security]]></category>
                        <category><![CDATA[cloudflare]]></category>
                        <category><![CDATA[nginx]]></category>
                    </item>
                <item>
            <title><![CDATA[# notACMS — Symfony Static Site Generator]]></title>
            <link>https://holas.pl/blog/notacms-static-site-generator/</link>
            <guid isPermaLink="true">https://holas.pl/blog/notacms-static-site-generator/</guid>
                        <pubDate>Thu, 09 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[notACMS is a Symfony-based static site generator I built in early 2026 to replace 19 years of WordPress on holas.pl. No database, no CMS admin panel, no PHP involved in serving content. The entire site — blog posts, category pages, tag listings, archive months, search, RSS feed, sitemap — is pre-rendered to static HTML files and served by nginx. The codebase is open-source under Apache 2.0 — see G…]]></description>
            <content:encoded><![CDATA[<p>notACMS is a Symfony-based static site generator I built in early 2026 to replace 19 years of WordPress on holas.pl. No database, no CMS admin panel, no PHP involved in serving content. The entire site — blog posts, category pages, tag listings, archive months, search, RSS feed, sitemap — is pre-rendered to static HTML files and served by nginx.</p>
<p>The codebase is open-source under Apache 2.0 — see <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub / notACMS</a>. It has 368 PHPUnit tests, CI/CD via GitHub Actions, and a local override pattern that lets users customise templates, CSS, and translations without forking.</p>
<h2>Architecture<a id="architecture" href="#architecture" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>All content lives in Markdown files with YAML frontmatter:</p>
<pre><code>content/
├── blog/
│   └── tutorials/
│       └── my-post/
│           ├── en.md   ← English version
│           ├── pl.md   ← Polish version
│           └── files/  ← images, served at /media/my-post/
└── pages/
    └── about/
        ├── en.md
        └── pl.md
</code></pre>
<p><code>ContentTreeBuilder</code> scans the filesystem, parses each file with <code>league/commonmark</code> (GitHub Flavoured Markdown + YAML frontmatter), and builds a typed <code>ContentTree</code> — an in-memory index of all posts and pages for a given locale. Tags, categories, archive months, and URL maps are computed from that index.</p>
<p>The static build uses Symfony sub-requests: <code>HttpKernelInterface::handle()</code> with <code>SUB_REQUEST</code> renders every URL through the full kernel without touching the network. If a URL works in development, it will be in the static build. No separate template engine, no build configuration.</p>
<h2>Key Features<a id="key-features" href="#key-features" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li><strong>Multi-language</strong> — co-located <code>en.md</code> + <code>pl.md</code> in the same directory, automatic translation mapping, <code>hreflang</code> tags, language switcher</li>
<li><strong>Search</strong> — Pagefind WASM-based static index, client-side, no Elasticsearch or Algolia</li>
<li><strong>Responsive images</strong> — automatic srcset generation via ImageMagick, configured variant widths</li>
<li><strong>Draft &amp; scheduled posts</strong> — dev-only preview toggles, scheduled posts automatically published at build time</li>
<li><strong>Contact form</strong> — Cloudflare Turnstile CAPTCHA, tight CSP, single POST endpoint</li>
<li><strong>Styleguide</strong> — dev-only page documenting every component with real CSS classes</li>
<li><strong>Local override pattern</strong> — <code>local/</code> directory merges on top of base at build time, customise without forking</li>
</ul>
<h2>Tech Stack<a id="tech-stack" href="#tech-stack" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>PHP 8.5, Symfony 7.x</li>
<li>Twig templates, SCSS (dart-sass via symfonycasts/sass-bundle)</li>
<li>Symfony AssetMapper (no webpack, no Vite, no Node.js build pipeline)</li>
<li>nginx + PHP-FPM (two Docker containers in production)</li>
<li>Pagefind for search</li>
<li>PHPUnit 13, PHPStan level 6, Rector, PHP CS Fixer</li>
</ul>
<h2>Why Build It?<a id="why-build-it" href="#why-build-it" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application because I already know Symfony well, and owning the full stack turned out to have real advantages: the same templates, controllers, and content pipeline serve both development and production. There is no &quot;build-time template engine&quot; separate from the &quot;runtime template engine.&quot; It's just Symfony.</p>
<p>The full story of why WordPress had to go is in <a href="/blog/why-i-left-wordpress/">Why I left WordPress</a>. The architecture deep dive is in <a href="/blog/symfony-static-site-generator/">Symfony as a Static Site Generator</a>.</p>
<h2>Open Source<a id="open-source" href="#open-source" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>notACMS is available on <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/notACMS">GitHub</a> under Apache 2.0. The preparation for release — testing, AI code review, security fixes, the local override pattern — is documented in the <a href="/blog/368-tests-static-site-generator/">open-source series</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/notacms/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architecture]]></category>
                    </item>
                <item>
            <title><![CDATA[Symfony as a Static Site Generator — How holas.pl Works]]></title>
            <link>https://holas.pl/blog/symfony-static-site-generator/</link>
            <guid isPermaLink="true">https://holas.pl/blog/symfony-static-site-generator/</guid>
                        <pubDate>Thu, 02 Apr 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is part 2 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. Part 1 covers why WordPress had to go. After deciding to leave WordPress, the question was what to replace it with. Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application instead — not because the existing tools are inadequate, but because I al…]]></description>
            <content:encoded><![CDATA[<p><em>This is part 2 of a series on migrating holas.pl from WordPress to a custom Symfony-based static site generator. <a href="/blog/why-i-left-wordpress/">Part 1</a> covers why WordPress had to go.</em></p>
<hr />
<p>After <a href="/blog/why-i-left-wordpress/">deciding to leave WordPress</a>, the question was what to replace it with. Hugo, Jekyll, and Eleventy were obvious candidates. I chose to build a custom Symfony application instead — not because the existing tools are inadequate, but because I already know Symfony well, and owning the full stack turned out to have real advantages.</p>
<h2>The Core Idea<a id="the-core-idea" href="#the-core-idea" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The site works in two modes:</p>
<ul>
<li><strong>Development</strong> — Symfony handles requests dynamically. Edit a Markdown file, refresh the browser, see the result. Standard Symfony dev workflow with the profiler toolbar.</li>
<li><strong>Production build</strong> — a single console command renders every URL to an HTML file on disk. nginx serves those files directly. PHP is never called for content delivery.</li>
</ul>
<p>The same templates, controllers, and content pipeline serve both modes. There is no &quot;build-time template engine&quot; separate from the &quot;runtime template engine.&quot; It's just Symfony.</p>
<h2>Content Pipeline<a id="content-pipeline" href="#content-pipeline" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>All content lives in Markdown files with YAML frontmatter:</p>
<pre><code>content/
├── blog/
│   └── tutorials/
│       └── my-post/
│           ├── en.md   ← English version
│           ├── pl.md   ← Polish version
│           └── files/  ← images, served at /media/my-post/
└── pages/
    └── about/
        ├── en.md
        └── pl.md
</code></pre>
<p><code>ContentTreeBuilder</code> scans the filesystem, parses each file with <code>league/commonmark</code> (GitHub Flavoured Markdown + YAML frontmatter), and builds a typed <code>ContentTree</code> — an in-memory index of all posts and pages for a given locale. Tags, categories, archive months, and URL maps are computed from that index.</p>
<p><code>ContentItem</code> is a <code>final readonly</code> value object. No database, no ORM, no migrations. Adding a post means creating a directory with two Markdown files and running the build.</p>
<h2>The Build Command — Symfony Sub-requests<a id="the-build-command--symfony-sub-requests" href="#the-build-command--symfony-sub-requests" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The static build uses a technique that's specific to Symfony and distinguishes this approach from Hugo or Jekyll: it calls <code>HttpKernelInterface::handle()</code> to make <strong>sub-requests</strong> — internal PHP calls that go through the full Symfony kernel without touching the network.</p>
<pre><code class="language-php">$request = Request::create($url);
$request-&gt;attributes-&gt;set('_static_build', true);
$response = $this-&gt;kernel-&gt;handle($request, HttpKernelInterface::SUB_REQUEST, false);

if ($response-&gt;getStatusCode() &lt; 400) {
    file_put_contents($outputPath, $response-&gt;getContent());
}
</code></pre>
<p>For every URL — blog posts, category pages, tag pages, archive months, static pages, error pages, RSS feeds, the sitemap — the build command creates a request, runs it through Symfony, and writes the HTML to disk. The URL <code>/blog/</code> becomes <code>public/static/blog/index.html</code>. The URL <code>/sitemap.xml</code> becomes <code>public/static/sitemap.xml</code>.</p>
<p>No separate template engine to learn. No build configuration. If a URL works in development, it will be in the static build.</p>
<h2>nginx Serves Everything<a id="nginx-serves-everything" href="#nginx-serves-everything" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>In production, nginx handles all content delivery:</p>
<pre><code class="language-nginx">location / {
    try_files /static$uri/index.html /static$uri/index.xml /static$uri $uri =404;
}

# PHP only for the contact form
location ~ ^/(api|pl/api)/ {
    fastcgi_pass $php_upstream;
}
</code></pre>
<p>For any incoming URL, nginx tries the pre-rendered HTML file first. PHP-FPM is only invoked for <code>/api/contact</code> — the single dynamic endpoint. Every blog post, category listing, tag page, and static page is a file served directly from disk. A Raspberry Pi 5 handles this trivially.</p>
<h2>No Database<a id="no-database" href="#no-database" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The content tree is built from Markdown files at build time. In production it's cached indefinitely in Symfony's filesystem cache (invalidated and rebuilt on every deploy). In development it's rebuilt on every request so file changes are immediately visible.</p>
<p>There is no MySQL, no schema, no migrations, no connection pooling, no slow queries. Adding content means creating files and running <code>ddev build</code>. Removing content means deleting files. The entire site history is in git.</p>
<h2>Multi-language Without a Database<a id="multi-language-without-a-database" href="#multi-language-without-a-database" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both Polish and English content lives in the same directory:</p>
<pre><code>content/blog/tutorials/my-post/
    en.md  → slug: &quot;blog/my-post&quot;      → /blog/my-post/
    pl.md  → slug: &quot;wpisy/moj-wpis&quot;    → /pl/wpisy/moj-wpis/
    files/ → images served at /media/my-post/
</code></pre>
<p>Co-location is the translation link. Two locale files in the same directory are automatically treated as translations of each other. <code>TranslationMapBuilder</code> constructs <code>{directoryKey → {locale → url}}</code> for <code>hreflang</code> tags and the language switcher. No <code>translation_key</code> field, no join table, no synchronisation to manage.</p>
<h2>Search with Pagefind<a id="search-with-pagefind" href="#search-with-pagefind" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Search is a WASM-based static index built by <a rel="nofollow noopener noreferrer" target="_blank" href="https://pagefind.app/">Pagefind</a> after the HTML files are generated:</p>
<pre><code class="language-bash">npx pagefind --site public/static --output-path public/pagefind
</code></pre>
<p>Pagefind reads the pre-rendered HTML, indexes <code>data-pagefind-body</code> regions, and generates a binary index in <code>public/pagefind/</code>. The search page loads this index client-side via a dynamic <code>import()</code>. No Elasticsearch, no Algolia, no server-side search query. The index is a set of static files.</p>
<h2>Redesign Simplicity<a id="redesign-simplicity" href="#redesign-simplicity" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Changing the site's appearance means editing Twig templates and SCSS. No React component library to update. No Node.js build pipeline. No WordPress child theme hierarchy. The entire frontend is:</p>
<ul>
<li><code>assets/styles/app.scss</code> — one entrypoint importing partials</li>
<li><code>templates/</code> — Twig templates</li>
<li><code>symfonycasts/sass-bundle</code> — compiles SCSS with dart-sass, no Node required</li>
</ul>
<p>To change the colour scheme: edit <code>_variables.scss</code>. To change the layout: edit a Twig template. Run <code>ddev build</code> and it's done.</p>
<h2>Trade-offs<a id="trade-offs" href="#trade-offs" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The main trade-off compared to Hugo or Jekyll is that this is custom code — I maintain it. The upside is that it's exactly what's needed, nothing more. There's no plugin ecosystem to navigate, no upgrade path to worry about, no feature flags for things I'll never use.</p>
<p>The only genuinely dynamic feature — the contact form — is covered in the <a href="/blog/contact-form-security/">next post</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/symfony-static-site-generator/symfony-static-site-generator.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[symfony]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[nginx]]></category>
                        <category><![CDATA[architecture]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[19 Years of WordPress — Why I Finally Quit]]></title>
            <link>https://holas.pl/blog/why-i-left-wordpress/</link>
            <guid isPermaLink="true">https://holas.pl/blog/why-i-left-wordpress/</guid>
                        <pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[holas.pl ran on WordPress from 2007 to early 2026. Nineteen years. In that time WordPress grew from a straightforward blogging platform into something I barely recognize — and maintaining it had become more work than maintaining the actual content. This is the first post in a series documenting the migration to a custom Symfony-based static site generator. The next posts cover the architecture, th…]]></description>
            <content:encoded><![CDATA[<p>holas.pl ran on WordPress from 2007 to early 2026. Nineteen years. In that time WordPress grew from a straightforward blogging platform into something I barely recognize — and maintaining it had become more work than maintaining the actual content.</p>
<p>This is the first post in a series documenting the migration to a custom Symfony-based static site generator. The next posts cover the <a href="/blog/symfony-static-site-generator/">architecture</a>, the <a href="/blog/contact-form-security/">contact form</a>, the <a href="/blog/dev-experience-two-containers/">developer workflow</a>, and <a href="/blog/building-with-ai-claude-code/">building the site with AI assistance</a>.</p>
<h2>The Update Treadmill<a id="the-update-treadmill" href="#the-update-treadmill" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>WordPress ships security updates frequently. Plugins ship updates independently. Themes do too. On a quiet week my update queue had three items. On a bad week it had fifteen. Each one required:</p>
<ol>
<li>Back up the database and files</li>
<li>Apply the update</li>
<li>Check that nothing broke</li>
</ol>
<p>That last step is the killer. WordPress plugins interact with each other in ways that are impossible to predict. An update to a caching plugin would break the SEO plugin's output. An update to the SEO plugin would change how sitemaps were generated. Every update was a small gamble.</p>
<p>For a portfolio site that publishes a post every few months, this maintenance overhead is absurd.</p>
<h2>Dozens of Plugins for Basic Features<a id="dozens-of-plugins-for-basic-features" href="#dozens-of-plugins-for-basic-features" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A fresh WordPress installation does almost nothing useful. To run a respectable portfolio blog you need plugins for:</p>
<ul>
<li><strong>SEO</strong> — Yoast or Rank Math, with their own settings screens, meta boxes, and update cycles</li>
<li><strong>Caching</strong> — W3 Total Cache or WP Super Cache, with complex configuration and edge cases</li>
<li><strong>Security</strong> — Wordfence or similar, scanning for intrusions, blocking IPs, sending daily reports</li>
<li><strong>Contact form</strong> — Contact Form 7 or Gravity Forms</li>
<li><strong>Backups</strong> — UpdraftPlus or similar, usually pushing to an external service</li>
<li><strong>Performance</strong> — image optimization, lazy loading, minification</li>
<li><strong>SMTP</strong> — because WordPress sends email through PHP <code>mail()</code> by default, which goes to spam</li>
</ul>
<p>Each plugin is code you don't control, maintained by someone else, potentially abandoned tomorrow, running on every page load.</p>
<p>When I started planning the migration I counted 22 active plugins.</p>
<h2>Bot Attacks and Login Problems<a id="bot-attacks-and-login-problems" href="#bot-attacks-and-login-problems" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>A WordPress login page is a known target. The URL is always <code>/wp-admin/</code> or <code>/wp-login.php</code>. Every bot on the internet knows this. The result: constant brute-force login attempts, around the clock.</p>
<p>I had Wordfence rate-limiting login attempts, blocking suspicious IPs, and sending me daily attack reports. It worked — but it was another moving part consuming server resources on a site that didn't need user logins at all. The only person logging in was me, once a month.</p>
<p>XML-RPC was another attack vector. WordPress enables it by default for pingbacks and the mobile app. Bots probe it constantly. The fix was an nginx rule to block it — but you only find out about it after seeing the traffic in your logs.</p>
<h2>PHP and MySQL on a Raspberry Pi<a id="php-and-mysql-on-a-raspberry-pi" href="#php-and-mysql-on-a-raspberry-pi" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>holas.pl runs on self-hosted hardware — first a <a href="/blog/home-server-banana-pi/">Banana Pi</a>, now a <a href="/blog/raspberry-pi-5-migration-to-nvme/">Raspberry Pi 5</a>. These are capable ARM boards, but they are not server-grade machines. Running PHP-FPM and MySQL simultaneously for a blog that changes once a month is wasteful.</p>
<p>A typical WordPress page load on that hardware: PHP parses the request, MySQL runs several queries to fetch post content, navigation data, sidebar widgets, and site options, PHP renders templates, every active plugin executes its hooks. All of this for content that was identical to the last request and will be identical to the next one.</p>
<p>With a static site, nginx serves a pre-rendered HTML file directly from disk. The ARM processor barely wakes up. Pages load faster than WordPress could parse the incoming request.</p>
<h2>The Database as a Liability<a id="the-database-as-a-liability" href="#the-database-as-a-liability" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Nineteen years of WordPress produces a database with thousands of rows of post metadata, options, post revisions, and transients. It needs:</p>
<ul>
<li>Regular backups (and testing that restores actually work)</li>
<li>Occasional cleanup — WordPress accumulates garbage: post revisions, expired transients, orphaned metadata rows</li>
<li>Monitoring for corruption after unclean shutdowns</li>
<li>A MySQL process running continuously, consuming memory</li>
</ul>
<p>A portfolio blog is a collection of text and images. Storing it in a relational database adds operational complexity without adding value.</p>
<h2>What I Kept<a id="what-i-kept" href="#what-i-kept" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The content. All 26 posts were exported and converted to Markdown files — titles, dates, categories, tags, images. The URLs were preserved exactly: 25 individual post redirects and 43 image path redirects are now baked into the nginx config. Search rankings survived the migration intact.</p>
<p>Everything else — the database, the plugins, the update queue, the <code>/wp-admin/</code> login page — is gone.</p>
<p>The <a href="/blog/symfony-static-site-generator/">next post</a> covers what replaced it: a custom Symfony application that generates static HTML files and serves them through nginx, with no PHP involved in delivering content to visitors.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/why-i-left-wordpress/why-i-left-wordpress.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[wordpress]]></category>
                        <category><![CDATA[php]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[static-site]]></category>
                        <category><![CDATA[performance]]></category>
                    </item>
                <item>
            <title><![CDATA[Let&#039;s Encrypt with Docker and Traefik — automatic HTTPS for every service]]></title>
            <link>https://holas.pl/blog/lets-encrypt-docker-traefik/</link>
            <guid isPermaLink="true">https://holas.pl/blog/lets-encrypt-docker-traefik/</guid>
                        <pubDate>Sun, 08 Mar 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is the follow-up to my 2016 Let&#039;s Encrypt post using acme.sh and Apache. That setup worked, but running certificates by hand doesn&#039;t scale once you&#039;re managing multiple services in Docker. Traefik solves this entirely. Traefik is a reverse proxy designed for container environments. It watches the Docker socket, discovers running containers, and automatically provisions Let&#039;s Encrypt certifica…]]></description>
            <content:encoded><![CDATA[<p><em>This is the follow-up to my <a href="/blog/free-ssl-with-lets-encrypt/">2016 Let's Encrypt post</a> using acme.sh and Apache. That setup worked, but running certificates by hand doesn't scale once you're managing multiple services in Docker. Traefik solves this entirely.</em></p>
<hr />
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://traefik.io/">Traefik</a> is a reverse proxy designed for container environments. It watches the Docker socket, discovers running containers, and automatically provisions Let's Encrypt certificates — no certbot, no cron, no manual config per domain.</p>
<p>The setup is split into two parts: a single Traefik instance that runs permanently, and per-service labels that tell Traefik how to route and which domain to certify.</p>
<h2>Part 1 — Traefik docker-compose.yml<a id="part-1--traefik-docker-composeyml" href="#part-1--traefik-docker-composeyml" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This runs once on the server. All other services route through it.</p>
<pre><code class="language-yaml">services:
    traefik:
        image: traefik:${TRAEFIK_VERSION}
        container_name: traefik
        command:
            - &quot;--providers.docker=true&quot;
            - &quot;--providers.docker.exposedbydefault=false&quot;
            - &quot;--entrypoints.web.address=:80&quot;
            - &quot;--entrypoints.websecure.address=:443&quot;
            # global HTTP → HTTPS redirect
            - &quot;--entrypoints.web.http.redirections.entrypoint.scheme=https&quot;
            - &quot;--entrypoints.web.http.redirections.entrypoint.to=websecure&quot;
            # Let's Encrypt
            - &quot;--certificatesResolvers.le.acme.email=${ACME_EMAIL}&quot;
            - &quot;--certificatesResolvers.le.acme.storage=acme.json&quot;
            - &quot;--certificatesResolvers.le.acme.tlsChallenge=true&quot;
        restart: always
        ports:
            - 80:80
            - 443:443
        networks:
            - web
        volumes:
            - /etc/localtime:/etc/localtime:ro
            - /var/run/docker.sock:/var/run/docker.sock
            - ./acme.json:/acme.json
        labels:
            - traefik.http.middlewares.gzip.compress=true

networks:
    web:
        external: true
</code></pre>
<p>A few things worth noting:</p>
<ul>
<li><strong><code>exposedbydefault=false</code></strong> — Traefik ignores containers unless they explicitly opt in via labels. Nothing gets exposed accidentally.</li>
<li><strong>TLS challenge</strong> — Traefik handles certificate issuance on port 443 directly. No need to temporarily expose port 80 for verification.</li>
<li><strong><code>acme.json</code></strong> — certificates are persisted to disk and survive container restarts. Create this file and set strict permissions before the first run:</li>
</ul>
<pre><code class="language-bash">touch acme.json &amp;&amp; chmod 600 acme.json
</code></pre>
<p>Traefik will refuse to start if the file has looser permissions.</p>
<ul>
<li><strong>External <code>web</code> network</strong> — create it once:</li>
</ul>
<pre><code class="language-bash">docker network create web
</code></pre>
<p>Every service that needs HTTP/S routing joins this network.</p>
<h2>Part 2 — Staging certificates for testing<a id="part-2--staging-certificates-for-testing" href="#part-2--staging-certificates-for-testing" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Before going live, test with Let's Encrypt's staging server to avoid hitting rate limits:</p>
<pre><code class="language-yaml"># add to traefik command:
- &quot;--log.level=DEBUG&quot;
- &quot;--certificatesresolvers.le.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory&quot;
</code></pre>
<p>Staging certs aren't trusted by browsers but are functionally identical for testing. Remove these lines when everything works.</p>
<h2>Part 3 — Adding a service<a id="part-3--adding-a-service" href="#part-3--adding-a-service" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>This is where it pays off. Any container gets HTTPS by adding four labels:</p>
<pre><code class="language-yaml">services:
    myapp:
        image: myapp:latest
        networks:
            - web
        labels:
            - traefik.enable=true
            - traefik.http.routers.myapp.rule=Host(`myapp.example.com`)
            - traefik.http.routers.myapp.entrypoints=websecure
            - traefik.http.routers.myapp.tls.certresolver=le

networks:
    web:
        external: true
</code></pre>
<p>Traefik picks up the container, requests a certificate from Let's Encrypt, and starts routing — no nginx config, no certbot run, no cron. Renewals happen automatically in the background.</p>
<h2>Bare-metal alternative<a id="bare-metal-alternative" href="#bare-metal-alternative" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>If you're not using Docker, <a rel="nofollow noopener noreferrer" target="_blank" href="https://certbot.eff.org/">Certbot</a> with the nginx plugin is the standard approach: <code>apt install certbot python3-certbot-nginx</code>, run <code>certbot --nginx -d mysite.com</code>, and a systemd timer handles renewals. Works well for a single server with a handful of sites. Once you're managing more services, the Traefik approach becomes worth the initial setup.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/lets-encrypt-modern/lets-encrypt-modern.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[security]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[traefik]]></category>
                        <category><![CDATA[ssl]]></category>
                    </item>
                <item>
            <title><![CDATA[Raspberry Pi 5: Migration to NVMe and service containerization]]></title>
            <link>https://holas.pl/blog/raspberry-pi-5-migration-to-nvme/</link>
            <guid isPermaLink="true">https://holas.pl/blog/raspberry-pi-5-migration-to-nvme/</guid>
                        <pubDate>Sat, 10 Jan 2026 00:00:00 +0000</pubDate>
                        <description><![CDATA[My previous server — a Banana Pi running Debian — had served its purpose for years, but performance limitations and lack of support for newer technologies prompted me to upgrade. The main goal was to move to a container-based architecture and eliminate SD cards in favor of the NVMe standard. Hardware specification The new setup was put together with maximum responsiveness and stability in mind: Un…]]></description>
            <content:encoded><![CDATA[<p>My previous server — a <a href="/blog/home-server-banana-pi/">Banana Pi</a> running Debian — had served its purpose for years, but performance limitations and lack of support for newer technologies prompted me to upgrade. The main goal was to move to a container-based architecture and eliminate SD cards in favor of the NVMe standard.</p>
<h2>Hardware specification<a id="hardware-specification" href="#hardware-specification" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The new setup was put together with maximum responsiveness and stability in mind:</p>
<ul>
<li><strong>Unit:</strong> Raspberry Pi 5 (8 GB RAM).</li>
<li><strong>Case:</strong> Argon NEO 5 M.2 NVMe — provides effective cooling and direct SSD support through a dedicated interface.</li>
<li><strong>Storage:</strong> Lexar 1 TB M.2 PCIe NVMe NM620. Moving from micro SD to NVMe significantly reduces latency and increases media durability.</li>
</ul>
<p><img data-full="/media/rpi5-migration/rpi5.webp" src="/media/rpi5-migration/rpi5.webp" alt="Raspberry Pi 5 with Argon NEO 5 case and NVMe drive" /></p>
<h2>Energy efficiency<a id="energy-efficiency" href="#energy-efficiency" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>One of the key arguments for choosing a Raspberry Pi as a 24/7 server is its low power draw. In the current configuration:</p>
<ul>
<li><strong>Idle:</strong> Power consumption at <strong>3 W</strong> (confirmed by a power meter reading).</li>
<li><strong>Stress (load):</strong> Power consumption rises to <strong>9–12 W</strong>.</li>
</ul>
<p>The performance-to-energy ratio makes this unit an extremely cost-effective solution.</p>
<h2>System and OS-level services<a id="system-and-os-level-services" href="#system-and-os-level-services" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Everything runs on <strong>Raspberry Pi OS</strong>. At the operating system level (outside Docker) I configured the key management and access services:</p>
<ul>
<li><strong>Remote access:</strong> A VPN based on the Wireguard protocol (<strong>PiVPN</strong>) provides a secure tunnel into the home network.</li>
<li><strong>Management:</strong> I use <strong>VNC</strong> for graphical interface access.</li>
<li><strong>File sharing:</strong> A standard <strong>Samba</strong> service for fast data access on the local network.</li>
</ul>
<h2>Docker environment<a id="docker-environment" href="#docker-environment" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The key change compared to the previous server is full containerization of network services. <strong>Traefik</strong> handles routing and automatic SSL certificate issuance.</p>
<p>Within Docker I currently maintain:</p>
<ol>
<li><strong>holas.pl</strong> — my website.</li>
<li><strong>Nextcloud</strong> — a private cloud for data synchronization.</li>
<li><strong>Immich</strong> — a solution for photo library backup and management.</li>
<li><strong>Traefik</strong> — a reverse proxy managing incoming traffic.</li>
</ol>
<h2>Growth potential<a id="growth-potential" href="#growth-potential" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Despite running several demanding services, the 8 GB Raspberry Pi 5 shows <strong>plenty of headroom in both computing power and RAM</strong>. Current utilization allows for adding more containers without any risk of degrading the already running systems. Moving to the NVMe standard means that database operations (especially in Immich and Nextcloud) are now instantaneous.</p>
<p><img data-full="/media/rpi5-migration/Zrzut-ekranu-2026-01-10-224632-1024x500.webp" src="/media/rpi5-migration/Zrzut-ekranu-2026-01-10-224632-1024x500.webp" alt="System load — htop" /></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/rpi5-migration/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[raspberry-pi]]></category>
                        <category><![CDATA[docker]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[homelab]]></category>
                        <category><![CDATA[self-hosted]]></category>
                    </item>
                <item>
            <title><![CDATA[ddev-sylius: A DDEV Boilerplate for Sylius 2.x]]></title>
            <link>https://holas.pl/blog/ddev-sylius-boilerplate/</link>
            <guid isPermaLink="true">https://holas.pl/blog/ddev-sylius-boilerplate/</guid>
                        <pubDate>Tue, 02 Dec 2025 00:00:00 +0000</pubDate>
                        <description><![CDATA[The project started during a remote Sylius setup — configured entirely over SSH from a phone. The process was repetitive enough to warrant automating, so I put together a DDEV boilerplate. It got shared publicly before I considered it finished, so I released it as an early alpha and iterated from there. After a year of internal use it reached v1.0.0, with full Sylius 2.x support, a clean structure…]]></description>
            <content:encoded><![CDATA[<p>The project started during a remote Sylius setup — configured entirely over SSH from a phone. The process was repetitive enough to warrant automating, so I put together a DDEV boilerplate. It got shared publicly before I considered it finished, so I released it as an early alpha and iterated from there.</p>
<p>After a year of internal use it reached v1.0.0, with full Sylius 2.x support, a clean structure, and everything I use day-to-day. Version 1.0.1 followed the next day with cross-platform fixes.</p>
<h2>Why Sylius setup is painful without tooling<a id="why-sylius-setup-is-painful-without-tooling" href="#why-sylius-setup-is-painful-without-tooling" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Sylius 2.x has a non-trivial local setup. It requires PHP 8.4, Composer, a database, a web server with the correct rewrite rules, Node.js and Yarn for compiling the admin assets (based on Webpack Encore), and the Symfony binary for console tasks. Getting all of those running consistently across developer machines — Windows, macOS, Linux — without a container setup is fragile. Versions drift, environment variables differ, paths conflict.</p>
<p>DDEV solves this by declaring the entire environment as configuration. The boilerplate bakes in the correct versions and wires them together so no one has to figure it out manually.</p>
<h2>What it configures<a id="what-it-configures" href="#what-it-configures" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The <code>.ddev/config.yaml</code> sets:</p>
<ul>
<li><strong>PHP 8.4</strong> with <code>apache-fpm</code> (Sylius 2.x requires PHP 8.2+; 8.4 is current stable)</li>
<li><strong>MariaDB 11.8</strong> — latest stable, with <code>upload_dirs</code> tuned to exclude <code>media/</code>, <code>node_modules/</code>, and <code>backups/</code> from Mutagen sync on macOS</li>
<li><strong>Composer 2</strong></li>
<li><strong>Ports</strong> 8123 (HTTP) and 8443 (HTTPS) to avoid conflicts with other local projects</li>
<li><strong>Xdebug disabled</strong> by default — enable with <code>ddev xdebug on</code> when needed</li>
</ul>
<h2>What's included<a id="whats-included" href="#whats-included" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/ddev-sylius">ddev-sylius</a> is a DDEV-based project template for Sylius 2.x. Clone it, run two commands, and you have a working local Sylius instance — no manual configuration of PHP versions, database, or web server.</p>
<p>Custom DDEV commands bundled with the boilerplate:</p>
<ul>
<li><code>ddev sylius-install</code> — full Sylius installation from scratch</li>
<li><code>ddev cc</code> — clear caches</li>
<li><code>ddev dist</code> — install dependencies and build assets (Composer + Yarn)</li>
<li><code>ddev yarn &lt;param&gt;</code> — Yarn commands inside the container</li>
<li><code>ddev security-checker</code> — scan for known vulnerabilities</li>
<li><code>ddev code-check</code> — run coding standards validation</li>
<li><code>ddev backup</code> / <code>ddev database-import</code> / <code>ddev files-import</code> — backup and restore database and media</li>
<li><code>ddev sylius-cleanup</code> — reset all data (useful when re-testing install flows)</li>
<li><code>ddev build-site</code> / <code>ddev rebuild-site</code> — full or partial project rebuild</li>
</ul>
<p>Tested on Windows 11 with WSL2 (Ubuntu 24.04), macOS Tahoe (Apple Silicon), and Linux with Docker.</p>
<h2>Getting started<a id="getting-started" href="#getting-started" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<pre><code class="language-bash">git clone https://github.com/holas1337/ddev-sylius my-project
cd my-project
ddev start
ddev sylius-install
</code></pre>
<p>That's it. A few minutes later you have a running Sylius storefront with an admin panel, accessible at the DDEV-generated local URL (default <code>https://ddev-sylius-boilerplate.ddev.site:8443</code>).</p>
<h2>Day-to-day workflow<a id="day-to-day-workflow" href="#day-to-day-workflow" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>After the initial install, the typical daily commands are:</p>
<pre><code class="language-bash">ddev start             # start the environment
ddev cc                # clear caches after config changes
ddev dist              # rebuild assets after frontend changes
ddev code-check        # check coding standards before committing
ddev backup            # snapshot database before a risky migration
</code></pre>
<p>For debugging, <code>ddev xdebug on</code> enables Xdebug, and <code>ddev exec bin/console &lt;command&gt;</code> gives direct access to the Symfony console inside the container.</p>
<h2>What changed in 1.0.1<a id="what-changed-in-101" href="#what-changed-in-101" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The day-after release fixed things that showed up during cross-platform testing: macOS-specific adjustments for Mutagen and upload directory exclusions, MariaDB upgraded from 11.4 to 11.8, and phpMyAdmin updated to the latest version.</p>
<p>The repository is on GitHub: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/holas1337/ddev-sylius">holas1337/ddev-sylius</a>. Issues and PRs welcome.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/ddev-sylius/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[ddev]]></category>
                        <category><![CDATA[sylius]]></category>
                        <category><![CDATA[symfony]]></category>
                        <category><![CDATA[open-source]]></category>
                        <category><![CDATA[e-commerce]]></category>
                    </item>
                <item>
            <title><![CDATA[How to limit Facebook tracking in your browser]]></title>
            <link>https://holas.pl/blog/is-facebook-tracking-us/</link>
            <guid isPermaLink="true">https://holas.pl/blog/is-facebook-tracking-us/</guid>
                        <pubDate>Tue, 27 Mar 2018 00:00:00 +0000</pubDate>
                        <description><![CDATA[Facebook tracks you across the web through the Like buttons, comment widgets, and invisible pixels embedded on millions of sites — even if you never click anything. A few browser extensions cut that reach significantly. uBlock Origin The essential first layer. uBlock Origin (Firefox, Chrome) blocks ads and trackers at the network level. With default filter lists enabled it already blocks most Face…]]></description>
            <content:encoded><![CDATA[<p>Facebook tracks you across the web through the Like buttons, comment widgets, and invisible pixels embedded on millions of sites — even if you never click anything. A few browser extensions cut that reach significantly.</p>
<h2>uBlock Origin<a id="ublock-origin" href="#ublock-origin" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The essential first layer. <strong>uBlock Origin</strong> (<a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/ublock-origin/">Firefox</a>, <a rel="nofollow noopener noreferrer" target="_blank" href="https://chromewebstore.google.com/detail/ublock-origin/cjpalhdlnbpafiamejdnhcphjbkeiagm">Chrome</a>) blocks ads and trackers at the network level. With default filter lists enabled it already blocks most Facebook tracking pixels and social widgets on third-party sites.</p>
<p><strong>Note for Chrome users:</strong> Google's Manifest V3 transition limits some uBlock Origin capabilities in Chrome. The full-featured version remains available in Firefox.</p>
<h2>Privacy Badger<a id="privacy-badger" href="#privacy-badger" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://privacybadger.org/">Privacy Badger</a> by the Electronic Frontier Foundation (<a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/privacy-badger17/">Firefox</a>, <a rel="nofollow noopener noreferrer" target="_blank" href="https://chromewebstore.google.com/detail/privacy-badger/pkehgijcmpdhfbdbbnkijodmdjhbjlgp">Chrome</a>) takes a different approach: instead of using a static blocklist, it learns which trackers follow you across sites and progressively blocks them. Works well alongside uBlock Origin.</p>
<h2>Facebook Container (Firefox only)<a id="facebook-container-firefox-only" href="#facebook-container-firefox-only" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://addons.mozilla.org/en-US/firefox/addon/facebook-container/">Facebook Container</a> is a Mozilla extension that isolates your Facebook session in a separate container tab. Facebook can't see your activity on other sites, and third-party Facebook widgets on other sites can't read your Facebook session cookies.</p>
<p>This is the most effective single extension specifically targeting Facebook's cross-site tracking — and it's built and maintained by Mozilla.</p>
<h2>Built-in browser protections<a id="built-in-browser-protections" href="#built-in-browser-protections" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Both <strong>Firefox</strong> and <strong>Brave</strong> ship with enhanced tracking protection enabled by default, which blocks many known trackers including Facebook's. If you use either browser, you already have a solid baseline — the extensions above add more granular control on top.</p>
<p>If you use Chrome, consider switching to Firefox for better default privacy.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/facebook-tracking-2018/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[security]]></category>
                        <category><![CDATA[privacy]]></category>
                    </item>
                <item>
            <title><![CDATA[Secure your site for free with Let&#039;s Encrypt SSL]]></title>
            <link>https://holas.pl/blog/free-ssl-with-lets-encrypt/</link>
            <guid isPermaLink="true">https://holas.pl/blog/free-ssl-with-lets-encrypt/</guid>
                        <pubDate>Wed, 24 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is a historical record of the setup I used in 2016 — acme.sh and Apache on Debian Jessie. It worked reliably at the time. For a modern approach using Docker and Traefik, see the followup post. Before Let&#039;s Encrypt, getting a browser-trusted SSL certificate meant paying $50–$300/year to a commercial CA (Comodo, DigiCert, GoDaddy), going through manual identity verification, and configuring ren…]]></description>
            <content:encoded><![CDATA[<p><em>This is a historical record of the setup I used in 2016 — acme.sh and Apache on Debian Jessie. It worked reliably at the time. For a modern approach using Docker and Traefik, see the followup post.</em></p>
<hr />
<p>Before Let's Encrypt, getting a browser-trusted SSL certificate meant paying $50–$300/year to a commercial CA (Comodo, DigiCert, GoDaddy), going through manual identity verification, and configuring renewal yourself. Let's Encrypt launched in 2015, left beta in April 2016, and changed all of that: free Domain Validation certificates, automated issuance via the ACME protocol, and 90-day validity with built-in renewal tooling. For small servers and personal sites that had been running plain HTTP, this was the first practical path to HTTPS.</p>
<p>The official Certbot client wasn't yet packaged for Debian Jessie, so I used one of the <a rel="nofollow noopener noreferrer" target="_blank" href="https://community.letsencrypt.org/t/list-of-client-implementations/2103">alternative clients</a> — a simple bash script: <a rel="nofollow noopener noreferrer" target="_blank" href="https://github.com/Neilpang/acme.sh">https://github.com/Neilpang/acme.sh</a></p>
<p>With this script you can get everything done in about 5 minutes.</p>
<h2>Prerequisites<a id="prerequisites" href="#prerequisites" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>Root access to the server</li>
<li>Apache running on Debian Jessie</li>
<li>Domain resolving to the server's public IP</li>
<li>Port 80 open (used by ACME HTTP-01 challenge to verify domain ownership)</li>
</ul>
<p>For this guide, assume:</p>
<pre><code>/root/.acme.sh/acme.sh   # where the client scripts live
mysite.com               # the domain you want a certificate for
/mnt/www/mysite.com      # the webroot for your site
/etc/apache2             # Apache installation with config files
</code></pre>
<h3>Step 1 — Download the client<a id="step-1--download-the-client" href="#step-1--download-the-client" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Go to (or create) <code>/root/.acme.sh/acme.sh</code> and run:</p>
<pre><code class="language-bash">git clone https://github.com/Neilpang/acme.sh
</code></pre>
<p>If you don't have git, download the files manually from the project page and unpack them.</p>
<h3>Step 2 — Create a symlink for convenience<a id="step-2--create-a-symlink-for-convenience" href="#step-2--create-a-symlink-for-convenience" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<pre><code class="language-bash">ln -s /root/.acme.sh/ /etc/apache2/letsencrypt
</code></pre>
<h3>Step 3 — Issue the certificate<a id="step-3--issue-the-certificate" href="#step-3--issue-the-certificate" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Make sure your site is reachable from the internet, then:</p>
<pre><code class="language-bash">./acme.sh issue /mnt/www/mysite.com/ mysite.com
</code></pre>
<p>Or, if you have aliases (e.g. <code>www.mysite.com</code>):</p>
<pre><code class="language-bash">./acme.sh issue /mnt/www/mysite.com/ mysite.com www.mysite.com
</code></pre>
<p>acme.sh places a temporary file in the webroot, Let's Encrypt fetches it to verify you control the domain, then issues the certificate. The files are saved to:</p>
<pre><code>/root/.acme.sh/mysite.com/
</code></pre>
<h3>Step 4 — Configure Apache<a id="step-4--configure-apache" href="#step-4--configure-apache" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Point Apache to the new certificates in your virtual host config:</p>
<pre><code class="language-apache">SSLCACertificateFile  /etc/apache2/letsencrypt/mysite.com/ca.cer
SSLCertificateFile    /etc/apache2/letsencrypt/mysite.com/mysite.com.cer
SSLCertificateKeyFile /etc/apache2/letsencrypt/mysite.com/mysite.com.key
</code></pre>
<p>Then reload Apache:</p>
<pre><code class="language-bash">service apache2 reload
</code></pre>
<p>Your site should now be served with a Let's Encrypt certificate.</p>
<h3>Step 5 — Auto-renewal<a id="step-5--auto-renewal" href="#step-5--auto-renewal" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h3>
<p>Certificates are valid for 90 days. To renew automatically, create an executable script, e.g. <code>acme_cron</code>:</p>
<pre><code class="language-sh">#!/bin/sh
/root/.acme.sh/acme.sh/acme.sh cron &gt;&gt; /var/log/le-renew.log
service apache2 reload
</code></pre>
<p>Drop it in <code>/etc/cron.daily</code> and you're done.</p>
<p><img data-full="/media/lets-encrypt-howto/holas.pl-ssl.webp" src="/media/lets-encrypt-howto/holas.pl-ssl.webp" alt="holas.pl with a Let's Encrypt certificate" /></p>
<p>The process isn't complicated — once set up, renewal is fully automatic.</p>
<hr />
<p><em>This setup served well until I moved everything to Docker. The modern approach using Traefik handles Let's Encrypt automatically — no scripts, no cron, no manual config. More on that: <a href="/blog/lets-encrypt-docker-traefik/">Let's Encrypt with Docker and Traefik</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/lets-encrypt-howto/letsencrypt-b2.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[security]]></category>
                        <category><![CDATA[linux]]></category>
                        <category><![CDATA[ssl]]></category>
                    </item>
                <item>
            <title><![CDATA[Temporary email addresses — register anywhere without exposing your inbox]]></title>
            <link>https://holas.pl/blog/temporary-email-address/</link>
            <guid isPermaLink="true">https://holas.pl/blog/temporary-email-address/</guid>
                        <pubDate>Mon, 15 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[You want to peek at something behind a registration wall — a PDF download, a demo, a forum thread. You don&#039;t need an account, you just need to get past the email verification. So you create yet another throwaway Gmail, wait for the confirmation link, then never touch it again. There&#039;s a cleaner way: temporary email addresses — ready in seconds, no account needed, gone after a few hours. Guerrilla …]]></description>
            <content:encoded><![CDATA[<p>You want to peek at something behind a registration wall — a PDF download, a demo, a forum thread. You don't need an account, you just need to get past the email verification. So you create yet another throwaway Gmail, wait for the confirmation link, then never touch it again.</p>
<p>There's a cleaner way: <strong>temporary email addresses</strong> — ready in seconds, no account needed, gone after a few hours.</p>
<h2>Guerrilla Mail<a id="guerrilla-mail" href="#guerrilla-mail" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://www.guerrillamail.com/">Guerrilla Mail</a> is the simplest option. Open the site, copy the generated address, use it. Any mail delivered there appears immediately — no refresh needed. The inbox is session-based: close the tab and it's gone.</p>
<p>You can also compose outgoing mail from the temporary address, which is occasionally useful for replying to confirmation emails.</p>
<h2>Dropmail<a id="dropmail" href="#dropmail" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://dropmail.me/en/">Dropmail</a> goes further. It's the better choice when you need the address to last or to actually receive something reliably.</p>
<p>What Dropmail offers beyond a basic inbox:</p>
<ul>
<li><strong>Forwarding to your real address</strong> — emails land in your normal inbox, so you don't need to keep a Dropmail tab open. Cancel forwarding at any time.</li>
<li><strong>Recovery key</strong> — save it and you can return to the same address later, even from a different device or browser session</li>
<li><strong>Two domain types</strong> — permanent domains for longer-lived addresses, rotating domains for short-term use</li>
<li><strong>Telegram bot</strong> — receive emails directly in Telegram without opening a browser</li>
<li><strong>Android app</strong> — manage temporary addresses from your phone</li>
</ul>
<p>No registration, no premium tier needed for any of the above.</p>
<h2>Apple Hide My Email<a id="apple-hide-my-email" href="#apple-hide-my-email" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>If you're on Apple devices with an iCloud+ subscription (included with any paid iCloud storage plan starting at 50 GB), you already have a built-in option: <strong>Hide My Email</strong>.</p>
<p>It generates a unique, random address that forwards to your real inbox — permanently, until you deactivate it. No separate app, no website to visit. It's integrated directly into iOS, iPadOS, and macOS:</p>
<ul>
<li><strong>Safari autofill</strong> — when you fill in an email field on a website, Safari offers to generate a hidden address automatically</li>
<li><strong>Settings → iCloud → Hide My Email</strong> — create and manage all your addresses in one place, see which ones are active, deactivate or delete any of them</li>
<li><strong>Sign in with Apple</strong> — when you use &quot;Sign in with Apple&quot; and choose to hide your email, Hide My Email generates the address automatically</li>
</ul>
<p>The key difference from Guerrilla Mail and Dropmail: these addresses are permanent aliases tied to your Apple ID, not throwaway inboxes. You control them, you can disable them, and mail always arrives in your real inbox. It's the most seamless option if you're already in the Apple ecosystem — no friction, no extra tools.</p>
<p>The catch: it requires a paid iCloud+ plan and only works on Apple devices.</p>
<h2>When to use which<a id="when-to-use-which" href="#when-to-use-which" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Use <strong>Guerrilla Mail</strong> when you just need a confirmation link and will close the tab in two minutes.</p>
<p>Use <strong>Dropmail</strong> when you need the address to survive beyond the browser session, or when you want emails forwarded somewhere you'll actually see them.</p>
<p>Use <strong>Apple Hide My Email</strong> when you're on iPhone/Mac, have iCloud+, and want zero-friction address generation built into the OS — no separate tools, permanent and manageable aliases.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/temp-email/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[security]]></category>
                    </item>
                <item>
            <title><![CDATA[Alfa Consilium – financial &amp; insurance consulting]]></title>
            <link>https://holas.pl/blog/alfa-consilium-financial-insurance-consulting/</link>
            <guid isPermaLink="true">https://holas.pl/blog/alfa-consilium-financial-insurance-consulting/</guid>
                        <pubDate>Fri, 12 Feb 2016 00:00:00 +0000</pubDate>
                        <description><![CDATA[Corporate website for Alfa Consilium sp. z o.o., a licensed insurance broker and financial advisory firm operating under supervision of the Polish Financial Supervision Authority (KNF). The project covered both visual design and WordPress implementation from scratch. What the site needed to do Insurance brokerage is a regulated industry. A broker&#039;s website isn&#039;t just a brochure — it needs to commu…]]></description>
            <content:encoded><![CDATA[<p>Corporate website for <a rel="nofollow noopener noreferrer" target="_blank" href="https://alfaconsilium.eu/">Alfa Consilium sp. z o.o.</a>, a licensed insurance broker and financial advisory firm operating under supervision of the Polish Financial Supervision Authority (KNF). The project covered both visual design and WordPress implementation from scratch.</p>
<h2>What the site needed to do<a id="what-the-site-needed-to-do" href="#what-the-site-needed-to-do" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Insurance brokerage is a regulated industry. A broker's website isn't just a brochure — it needs to communicate regulatory status (KNF licence number, professional liability insurance), distinguish the firm from agents (a broker represents the client, not the insurer), and build enough trust that a potential corporate client picks up the phone.</p>
<p>Alfa Consilium's service portfolio covered a wide range: D&amp;O insurance, cargo and carrier liability (OC spedytora / OC przewoźnika), machinery breakdown, leasing brokerage, factoring, receivables management, and risk management with on-site audits. Each service area needed a clear, standalone description — not a wall of text, but enough substance for a decision-maker to understand scope and contact the team.</p>
<h2>Design and implementation<a id="design-and-implementation" href="#design-and-implementation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The site was designed with a clean corporate aesthetic: structured layout, clear service navigation, and contact information accessible from every page. Financial sector clients — logistics companies, manufacturers, fleet operators — expect a professional presentation, not a startup landing page.</p>
<p>Implementation was on WordPress with a custom PHP theme built from scratch — no off-the-shelf theme. The template handled three responsive breakpoints: desktop, tablet, and mobile. Responsive design was still far from standard practice in Poland in 2016; most small corporate sites were still fixed-width.</p>
<p>WordPress was the right choice for the client: the content team could update service descriptions and news without developer involvement, and the admin interface required no training.</p>
<h2>What was delivered<a id="what-was-delivered" href="#what-was-delivered" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<ul>
<li>Full visual design (desktop + tablet + mobile)</li>
<li>Custom WordPress theme in PHP</li>
<li>Service pages for all insurance and financial product lines</li>
<li>Contact section with regulatory information (KNF supervision details, professional liability insurance)</li>
<li>Responsive layout across all three breakpoints</li>
</ul>
<p><img data-full="/media/alfa-consilium/alfaconsilium_screenshot.webp" src="/media/alfa-consilium/alfaconsilium_screenshot_crop.webp" alt="Alfa Consilium website" />
<em>Screenshot from the live site.</em></p>
<p>The company continues to operate — the original .pl domain now redirects to <strong><a rel="nofollow noopener noreferrer" target="_blank" href="https://alfaconsilium.eu/">alfaconsilium.eu</a></strong>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/alfa-consilium/ac.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[projects]]></category>
                                    <category><![CDATA[php]]></category>
                        <category><![CDATA[wordpress]]></category>
                        <category><![CDATA[rwd]]></category>
                    </item>
                <item>
            <title><![CDATA[Your own home server – Banana Pi]]></title>
            <link>https://holas.pl/blog/home-server-banana-pi/</link>
            <guid isPermaLink="true">https://holas.pl/blog/home-server-banana-pi/</guid>
                        <pubDate>Wed, 08 Jul 2015 00:00:00 +0000</pubDate>
                        <description><![CDATA[This is a historical record of my home server setup from 2015. It served reliably for several years before I migrated to a Raspberry Pi 5 in 2026. See how it evolved: Raspberry Pi 5: Migration to NVMe and service containerization. In 2015, single-board computers were becoming a practical option for a low-power always-on home server. The Raspberry Pi 2 was the obvious choice, but the Banana Pi offe…]]></description>
            <content:encoded><![CDATA[<p><em>This is a historical record of my home server setup from 2015. It served reliably for several years before I migrated to a Raspberry Pi 5 in 2026. See how it evolved: <a href="/blog/raspberry-pi-5-migration-to-nvme/">Raspberry Pi 5: Migration to NVMe and service containerization</a>.</em></p>
<hr />
<p>In 2015, single-board computers were becoming a practical option for a low-power always-on home server. The Raspberry Pi 2 was the obvious choice, but the Banana Pi offered something the Pi couldn't: a SATA port. For a server that would store media files and run backups, a real hard drive was a non-negotiable advantage over SD card storage.</p>
<h2>Hardware<a id="hardware" href="#hardware" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The Banana Pi is built around the Allwinner A20 SoC — a dual-core ARM Cortex-A7 running at 1 GHz. Key specs that made it the right choice:</p>
<ul>
<li><strong>CPU:</strong> Allwinner A20, 2× 1 GHz (Cortex-A7)</li>
<li><strong>RAM:</strong> 1 GB DDR3</li>
<li><strong>Gigabit Ethernet</strong> (vs 100 Mbit on the RPi 2)</li>
<li><strong>SATA port</strong> — the deciding factor. A SATA HDD is faster, more reliable, and orders of magnitude larger than any SD card</li>
</ul>
<p>The setup: Banana Pi in a case, 2.5&quot; SATA drive for storage, a 4 GB USB stick for backups.</p>
<h2>What it ran<a id="what-it-ran" href="#what-it-ran" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Running Bananian — a Banana Pi-specific Debian distribution (note: discontinued in 2016, superseded by <a rel="nofollow noopener noreferrer" target="_blank" href="https://www.armbian.com/">Armbian</a>) — the server handled:</p>
<ul>
<li><strong>Media server</strong> (minidlna — stream music and video to TV over DLNA without booting a full PC)</li>
<li><strong>SSH</strong> (headless remote management from anywhere)</li>
<li><strong>VPN</strong> (OpenVPN — safe use of public Wi-Fi, access to the home network remotely)</li>
<li><strong>VNC</strong> (TightVNC — graphical session when needed, e.g. for jDownloader)</li>
<li><strong>Web server</strong> (LAMP stack — this site ran on it, plus a few personal projects)</li>
<li><strong>Backups</strong> (bash scripts on cron, no extra tooling)</li>
<li><strong>Downloads</strong> (aria2 and curl for command-line; jDownloader in graphical mode when needed)</li>
</ul>
<h2>In practice<a id="in-practice" href="#in-practice" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Everything ran fast, reliably, and stable around the clock. The real benefit was power consumption: the Banana Pi draws around 4–8 W under typical load. Spinning up an old Core 2 Duo desktop (65 W TDP, plus the rest of the system) just to listen to music made no sense. An hour on the old machine equalled a full day on the Banana Pi.</p>
<p><img data-full="/media/banana-pi-server/banana_pi_server.webp" src="/media/banana-pi-server/banana_pi_server-300x169.webp" alt="Banana Pi — finished setup" />
<em>Banana Pi in its case with a SATA drive and an old 4 GB USB stick for backups.</em></p>
<p><img data-full="/media/banana-pi-server/banana_pi_server2.webp" src="/media/banana-pi-server/banana_pi_server2-300x151.webp" alt="Banana Pi — admin console" />
<em>Banana Pi from the admin console side.</em></p>
<p>If you have basic Linux admin skills and want a low-power always-on server, the Debian ecosystem gives you everything you need.</p>
<hr />
<p><em>This setup eventually reached its limits. In 2026 I replaced it with a Raspberry Pi 5 with NVMe storage and full Docker containerization. Read about it: <a href="/blog/raspberry-pi-5-migration-to-nvme/">Raspberry Pi 5: Migration to NVMe and service containerization</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/banana-pi-server/featured.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category><![CDATA[tutorials]]></category>
                                    <category><![CDATA[linux]]></category>
                        <category><![CDATA[hardware]]></category>
                        <category><![CDATA[homelab]]></category>
                        <category><![CDATA[self-hosted]]></category>
                    </item>
            </channel>
</rss>
