<?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>holas.pl</title>
        <link>https://holas.pl/</link>
        <description>Piece of web by Holas</description>
        <language>en</language>
        <atom:link href="https://holas.pl/feed/" rel="self" type="application/rss+xml"/>
                <lastBuildDate>Tue, 14 Apr 2026 00:00:00 +0000</lastBuildDate>
                        <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>tutorials</category>
                                    <category>symfony</category>
                        <category>php</category>
                        <category>security</category>
                        <category>cloudflare</category>
                        <category>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>projects</category>
                                    <category>php</category>
                        <category>symfony</category>
                        <category>static-site</category>
                        <category>nginx</category>
                        <category>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>tutorials</category>
                                    <category>symfony</category>
                        <category>php</category>
                        <category>static-site</category>
                        <category>nginx</category>
                        <category>architecture</category>
                        <category>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>tutorials</category>
                                    <category>wordpress</category>
                        <category>php</category>
                        <category>symfony</category>
                        <category>static-site</category>
                        <category>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>tutorials</category>
                                    <category>security</category>
                        <category>linux</category>
                        <category>docker</category>
                        <category>traefik</category>
                        <category>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: U…]]></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>tutorials</category>
                                    <category>raspberry-pi</category>
                        <category>docker</category>
                        <category>linux</category>
                        <category>homelab</category>
                        <category>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>projects</category>
                                    <category>ddev</category>
                        <category>sylius</category>
                        <category>symfony</category>
                        <category>open-source</category>
                        <category>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 Fac…]]></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>tutorials</category>
                                    <category>security</category>
                        <category>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>tutorials</category>
                                    <category>security</category>
                        <category>linux</category>
                        <category>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>tutorials</category>
                                    <category>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 comm…]]></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>projects</category>
                                    <category>php</category>
                        <category>wordpress</category>
                        <category>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>tutorials</category>
                                    <category>linux</category>
                        <category>hardware</category>
                        <category>homelab</category>
                        <category>self-hosted</category>
                    </item>
                <item>
            <title><![CDATA[holas.pl – new responsive design (2015)]]></title>
            <link>https://holas.pl/blog/holas-pl-new-rwd-design/</link>
            <guid isPermaLink="true">https://holas.pl/blog/holas-pl-new-rwd-design/</guid>
                        <pubDate>Fri, 12 Jun 2015 00:00:00 +0000</pubDate>
                        <description><![CDATA[After seven years on the old fixed-width design, I rebuilt holas.pl from scratch in 2015 with a fully responsive WordPress theme. An archived snapshot of this version is available on the Wayback Machine. Why a rebuild, not a patch# The old design used two separate templates: a full desktop layout and a mobile skin served via user-agent detection. That approach had worked in 2008, but by 2015 it ha…]]></description>
            <content:encoded><![CDATA[<p>After seven years on the <a href="/blog/holas-pl-old-design/">old fixed-width design</a>, I rebuilt holas.pl from scratch in 2015 with a fully responsive WordPress theme. An archived snapshot of this version is available on the <a rel="nofollow noopener noreferrer" target="_blank" href="https://web.archive.org/web/20161108123944/https://holas.pl/">Wayback Machine</a>.</p>
<h2>Why a rebuild, not a patch<a id="why-a-rebuild-not-a-patch" href="#why-a-rebuild-not-a-patch" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The old design used two separate templates: a full desktop layout and a mobile skin served via user-agent detection. That approach had worked in 2008, but by 2015 it had become a maintenance liability — any content or style change needed to be applied twice, and the growing variety of device sizes made the binary desktop/mobile split increasingly inadequate. Tablets, phablets, and high-DPI screens all fell into awkward territory.</p>
<p>The decision was a full rebuild rather than trying to bolt responsiveness onto the old codebase. Starting from scratch meant cleaner SCSS, a proper semantic HTML structure, and no legacy hacks to carry forward.</p>
<h2>Technical implementation<a id="technical-implementation" href="#technical-implementation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The theme was custom-built on WordPress — no off-the-shelf theme, no page builder. The implementation covered:</p>
<ul>
<li><strong>PHP templates</strong> following the WordPress template hierarchy (<code>single.php</code>, <code>archive.php</code>, <code>page.php</code>, <code>functions.php</code>)</li>
<li><strong>SCSS</strong> compiled to a single stylesheet — variables for colors, spacing, and breakpoints</li>
<li><strong>Fluid grid</strong> with three breakpoints: mobile, tablet, and desktop</li>
<li><strong>Single codebase</strong> — one set of templates, one stylesheet, all screen sizes handled through CSS media queries</li>
</ul>
<h2>What else changed at the same time<a id="what-else-changed-at-the-same-time" href="#what-else-changed-at-the-same-time" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The rebuild coincided with a server migration — the site moved to a self-hosted Banana Pi running Debian 24/7. That setup is <a href="/blog/home-server-banana-pi/">documented separately</a>.</p>
<h2>What came after<a id="what-came-after" href="#what-came-after" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The responsive rebuild worked well and the design held up for several years. But working with WordPress increasingly meant fighting its abstractions rather than building with them — the plugin ecosystem, the PHP templating constraints, the deployment story. In 2026 I replaced it entirely — the full story is in <a href="/blog/why-i-left-wordpress/">Why I left WordPress</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/holas-rwd-design/holas.pl_-_nowa_wersja_RWD.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>php</category>
                        <category>wordpress</category>
                        <category>rwd</category>
                        <category>scss</category>
                    </item>
                <item>
            <title><![CDATA[PHPers Toruń #1]]></title>
            <link>https://holas.pl/blog/phpers-torun-1/</link>
            <guid isPermaLink="true">https://holas.pl/blog/phpers-torun-1/</guid>
                        <pubDate>Mon, 01 Jun 2015 00:00:00 +0000</pubDate>
                        <description><![CDATA[PHPers is one of the largest PHP developer communities in Poland — a network of meetups running in cities including Warsaw, Kraków, Wrocław, Poznań, and Trójmiasto. Each chapter brings together local PHP developers for talks, discussions, and networking around the PHP ecosystem. On 27 July 2015 at 18:00, the first PHPers meetup in Toruń took place at Krajina Piva, Rynek Nowomiejski 8. I co-organis…]]></description>
            <content:encoded><![CDATA[<p><a rel="nofollow noopener noreferrer" target="_blank" href="https://phpers.pl/">PHPers</a> is one of the largest PHP developer communities in Poland — a network of meetups running in cities including Warsaw, Kraków, Wrocław, Poznań, and Trójmiasto. Each chapter brings together local PHP developers for talks, discussions, and networking around the PHP ecosystem.</p>
<p>On 27 July 2015 at 18:00, the first PHPers meetup in Toruń took place at Krajina Piva, Rynek Nowomiejski 8. I co-organised it together with Szymon Skowroński. Starting a new city chapter meant establishing a regular meeting place for PHP developers in the region — and 57 people attended, with another 34 marking themselves as interested.</p>
<h2>Talks<a id="talks" href="#talks" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Three talks covered a range of PHP topics:</p>
<ul>
<li><strong>Leszek Prabucki</strong> — <em>Podstawy programowania obiektywnego w PHP</em> (Basics of objective programming in PHP)</li>
<li><strong>Łukasz Lubosz</strong> — <em>Blackfire — inteligentny profiler</em> (Blackfire — the intelligent profiler)</li>
<li><strong>Leszek Krupiński</strong> — <em>Co PHP? zepsuje w Twoim kodzie</em> (What will PHP? break in your code)</li>
</ul>
<h2>Sponsors<a id="sponsors" href="#sponsors" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The event was made possible by support from <a rel="nofollow noopener noreferrer" target="_blank" href="https://cocoders.com/">Cocoders</a>, <a rel="nofollow noopener noreferrer" target="_blank" href="https://ecenter.pl/">ecenter.pl</a>, and <a rel="nofollow noopener noreferrer" target="_blank" href="https://www.facebook.com/krajinapiv">Krajina Piva</a>, who hosted us.</p>
<h2>Why community matters<a id="why-community-matters" href="#why-community-matters" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Organising events like this is something I value alongside day-to-day development work. Building a local developer network means better knowledge sharing, stronger connections between developers in the region, and a more active local tech scene. The 91 total RSVPs for a first-ever meetup in Toruń showed the appetite was there.</p>
<p>The full event details are on <a rel="nofollow noopener noreferrer" target="_blank" href="https://www.facebook.com/events/krajina-piva/phpers-toru%C5%84-1/1606550216228709/">Facebook</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/phpers-torun-1/torun_night.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>community</category>
                                    <category>php</category>
                        <category>community</category>
                    </item>
                <item>
            <title><![CDATA[Bifor – venue site, Gdańsk]]></title>
            <link>https://holas.pl/blog/bifor-new-venue-gdansk/</link>
            <guid isPermaLink="true">https://holas.pl/blog/bifor-new-venue-gdansk/</guid>
                        <pubDate>Mon, 05 Jan 2015 00:00:00 +0000</pubDate>
                        <description><![CDATA[Bifor — full name B!FOR FOOD &amp;amp; DRINK &amp;amp; FRIENDS — was a Gdańsk venue that positioned itself as a local meeting point: food, drinks, sport, culture, live music, and events, all under one roof. The tagline on the site said it plainly: &amp;quot;WIESZ, COŚ SIĘ DZIEJE_&amp;quot; (You know, something&#039;s happening). What the site needed to do# The site had to communicate the full scope of what Bifor offer…]]></description>
            <content:encoded><![CDATA[<p>Bifor — full name <strong>B!FOR FOOD &amp; DRINK &amp; FRIENDS</strong> — was a Gdańsk venue that positioned itself as a local meeting point: food, drinks, sport, culture, live music, and events, all under one roof. The tagline on the site said it plainly: <em>&quot;WIESZ, COŚ SIĘ DZIEJE_&quot;</em> (You know, something's happening).</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>The site had to communicate the full scope of what Bifor offered and give visitors a reason to come in. The navigation reflected that breadth: <strong>Aktualności</strong> (News), <strong>O nas</strong> (About), <strong>Kuchnia</strong> (Kitchen), <strong>Kultura</strong> (Culture), <strong>Sport</strong>, <strong>Napoje i Alko</strong> (Drinks), and <strong>Kontakt</strong>. A prominent table reservation CTA with a phone number appeared on every page, and a Facebook Like Box kept the social presence integrated with the site.</p>
<h2>Implementation<a id="implementation" href="#implementation" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The site was built on WordPress with a custom PHP theme, implemented from a designer-provided mockup — no off-the-shelf template. The implementation covered:</p>
<ul>
<li><strong>Three responsive breakpoints</strong> — desktop, tablet, and mobile — with the layout adapting at each</li>
<li><strong>PHP template files</strong> following the WordPress template hierarchy</li>
<li><strong>CMS-managed content</strong> so the client could update news, events, the menu, and opening hours independently</li>
<li><strong>Facebook integration</strong> via the Like Box widget</li>
</ul>
<p>My role was converting the designer's static mockup into a fully functional WordPress theme. The visual identity — dark colour scheme, bold typography, the <em>B!FOR</em> logo — came from the design brief; the job was pixel-accurate implementation with clean, maintainable code.</p>
<p><em>Site no longer live.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/bifor-gdansk/bifor_mockup.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>php</category>
                        <category>wordpress</category>
                        <category>rwd</category>
                    </item>
                <item>
            <title><![CDATA[Fabryka Kształtów]]></title>
            <link>https://holas.pl/blog/fabryka-ksztaltow/</link>
            <guid isPermaLink="true">https://holas.pl/blog/fabryka-ksztaltow/</guid>
                        <pubDate>Thu, 09 Oct 2014 00:00:00 +0000</pubDate>
                        <description><![CDATA[Static business card site for Fabryka Kształtów — a Toruń/Chełmża area company specialising in advertising materials, CNC/3D cutting, and plexi fabrication. Their tagline: &amp;quot;Jesteśmy kreatywni dla Ciebie!&amp;quot; (We&#039;re creative for you). What the client does# Fabryka Kształtów produces advertising and promotional materials: display lettering, lightboxes, plexi shelves and stands, decorative wal…]]></description>
            <content:encoded><![CDATA[<p>Static business card site for Fabryka Kształtów — a Toruń/Chełmża area company specialising in advertising materials, CNC/3D cutting, and plexi fabrication. Their tagline: <em>&quot;Jesteśmy kreatywni dla Ciebie!&quot;</em> (We're creative for you).</p>
<h2>What the client does<a id="what-the-client-does" href="#what-the-client-does" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Fabryka Kształtów produces advertising and promotional materials: display lettering, lightboxes, plexi shelves and stands, decorative wall elements, car wrapping, and custom 3D forms and reliefs. On the services side, they run CNC/3D cutting equipment (working area 2000×3000×200 mm) for aluminium, soft metals, dibond, alucobond, plastics (plexi, PCV, HIPS), plywood, and MDF — plus a cutting plotter (up to 1250 mm wide) for standard, flex, velour, car, and magnetic vinyls.</p>
<h2>The site<a id="the-site" href="#the-site" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The structure was deliberately minimal — three pages matching the questions a potential client would ask:</p>
<ul>
<li><strong>Kim jesteśmy?</strong> (Who are we?) — company background</li>
<li><strong>Co robimy?</strong> (What do we do?) — full service and production list</li>
<li><strong>Jak nas znaleźć?</strong> (How to find us?) — contact and address</li>
</ul>
<p>The choice to go static was deliberate: a business card site with no dynamic content needs no CMS. Pure HTML/CSS/JS keeps the site fast, cheap to host, and free of maintenance overhead.</p>
<p>Two breakpoints — desktop and mobile — covered the use case without overengineering. In 2014, three-breakpoint RWD was not yet the default expectation for a small business site like this.</p>
<p><img data-full="/media/fabryka-ksztaltow/fk_komputer.webp" src="/media/fabryka-ksztaltow/fk_komputer_crop.webp" alt="Fabryka Kształtów — desktop" />
<em>Desktop version.</em></p>
<p><img data-full="/media/fabryka-ksztaltow/fk_telefon.webp" src="/media/fabryka-ksztaltow/fk_telefon_crop.webp" alt="Fabryka Kształtów — mobile" />
<em>Mobile version.</em></p>
<p><em>The design was completed but never went into production.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/fabryka-ksztaltow/fabryka_ksztaltow.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>js</category>
                        <category>rwd</category>
                    </item>
                <item>
            <title><![CDATA[EKO TREND INWEST]]></title>
            <link>https://holas.pl/blog/eko-trend-inwest/</link>
            <guid isPermaLink="true">https://holas.pl/blog/eko-trend-inwest/</guid>
                        <pubDate>Wed, 04 Jul 2012 00:00:00 +0000</pubDate>
                        <description><![CDATA[Corporate website for EKO TREND INWEST Sp. z o.o., a Warsaw-area construction and real estate investment company with nearly 30 years of experience in the sector. Built on QuickCMS with a custom theme. What the company does# EKO TREND INWEST builds and sells single-family homes on their own plots in the Warsaw area. Beyond new builds, they also handle construction and renovation of residential, ut…]]></description>
            <content:encoded><![CDATA[<p>Corporate website for EKO TREND INWEST Sp. z o.o., a Warsaw-area construction and real estate investment company with nearly 30 years of experience in the sector. Built on <a rel="nofollow noopener noreferrer" target="_blank" href="https://opensolution.org/system-cms-quick-cms.html">QuickCMS</a> with a custom theme.</p>
<h2>What the company does<a id="what-the-company-does" href="#what-the-company-does" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>EKO TREND INWEST builds and sells single-family homes on their own plots in the Warsaw area. Beyond new builds, they also handle construction and renovation of residential, utility, and industrial buildings, and offer credit advisory services for clients financing investments.</p>
<p>Their homepage positioned the company on quality and reliability: own workforce, vetted subcontractors, premium materials from established suppliers, price guarantees backed by contract. Indicative pricing for traditional single-family builds started at 350 PLN/m² for shell construction (2–3 months), developer standard at 5–6 months, and turnkey at around 8 months.</p>
<h2>The site<a id="the-site" href="#the-site" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><img data-full="/media/eko-trend-inwest/ekotrendinwest_screenshot.webp" src="/media/eko-trend-inwest/ekotrendinwest_screenshot_crop.webp" alt="EKO TREND INWEST — homepage" /></p>
<p>The site had eight sections reflecting the full scope of what a prospective buyer would want to know: <strong>Osiedle</strong> (the housing estate), <strong>Lokalizacja</strong> (location), <strong>Plan osiedla</strong> (estate plan), <strong>Technologia wykonania</strong> (construction technology), <strong>Galeria / Realizacje</strong> (gallery and completed projects), <strong>Doradztwo kredytowe</strong> (credit advisory), and <strong>Kontakt</strong>. The dark charcoal and green colour scheme matched the &quot;EKO&quot; branding, with property photography throughout.</p>
<p>The CMS choice — <a rel="nofollow noopener noreferrer" target="_blank" href="https://opensolution.org/system-cms-quick-cms.html">QuickCMS</a> — gave the client a lightweight, editable back-end without the overhead of WordPress. My role covered the custom theme and full implementation.</p>
<p><em>Site no longer live — archived snapshot on <a rel="nofollow noopener noreferrer" target="_blank" href="https://web.archive.org/web/20170515205757/http://ekotrendinwest.pl/">Wayback Machine</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/eko-trend-inwest/ekotrendinwest.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>php</category>
                        <category>cms</category>
                    </item>
                <item>
            <title><![CDATA[HR Management]]></title>
            <link>https://holas.pl/blog/hr-management/</link>
            <guid isPermaLink="true">https://holas.pl/blog/hr-management/</guid>
                        <pubDate>Tue, 24 Apr 2012 00:00:00 +0000</pubDate>
                        <description><![CDATA[Full site for HR Management sp. z o.o. — one of the longest-running consulting firms on the Polish market, combining insurance brokerage with a directly affiliated law firm. Built on QuickCMS with a custom theme, replacing the static placeholder delivered two months earlier. What the company does# HR Management&#039;s differentiator was the breadth and depth of integrated services: insurance advisory, …]]></description>
            <content:encoded><![CDATA[<p>Full site for HR Management sp. z o.o. — one of the longest-running consulting firms on the Polish market, combining insurance brokerage with a directly affiliated law firm. Built on <a rel="nofollow noopener noreferrer" target="_blank" href="https://opensolution.org/system-cms-quick-cms.html">QuickCMS</a> with a custom theme, replacing the <a href="/blog/hr-management-business-card/">static placeholder</a> delivered two months earlier.</p>
<h2>What the company does<a id="what-the-company-does" href="#what-the-company-does" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>HR Management's differentiator was the breadth and depth of integrated services: insurance advisory, legal advisory through an affiliated law firm (<em>kancelaria radców prawnych</em>), insurance brokerage, claims handling (representing clients before insurance companies and in court), and consulting for risk assessment. As CEO dr Tomasz Kamiński put it — that combination of lawyers, insurance brokers, and consultants under one roof was rare on the Polish insurance and legal market at the time.</p>
<p>The news section covered topics like D&amp;O (Directors &amp; Officers) coverage for company boards, construction insurance case studies, and medical events insurance. By 2013, the company had expanded to the German market.</p>
<h2>The site<a id="the-site" href="#the-site" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><img data-full="/media/hr-management/hr_screenshot.webp" src="/media/hr-management/hr_screenshot_crop.webp" alt="HR Management — homepage" /></p>
<p>The homepage featured a service grid of six tiles — <strong>O nas</strong>, <strong>Doradztwo prawne</strong>, <strong>Consulting</strong>, <strong>Pośrednictwo ubezpieczeniowe</strong>, <strong>Likwidacja szkód</strong>, <strong>Dokumenty</strong> — alongside a news feed with dated articles. Navigation was minimal: <strong>Strona główna</strong>, <strong>Aktualności</strong>, <strong>O nas</strong>, <strong>Kontakt</strong>. White and grey background with orange/blue accents, matching the corporate identity.</p>
<p>The two-phase approach worked well: the <a href="/blog/hr-management-business-card/">business card site</a> gave the client an immediate online presence while the full CMS-backed site was designed and built. QuickCMS gave the client full editorial control over news and content.</p>
<p><em>Site still live at <a rel="nofollow noopener noreferrer" target="_blank" href="http://hr.com.pl/">hr.com.pl</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/hr-management/hr.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>php</category>
                        <category>cms</category>
                    </item>
                <item>
            <title><![CDATA[HR Management – business card site]]></title>
            <link>https://holas.pl/blog/hr-management-business-card/</link>
            <guid isPermaLink="true">https://holas.pl/blog/hr-management-business-card/</guid>
                        <pubDate>Sun, 19 Feb 2012 00:00:00 +0000</pubDate>
                        <description><![CDATA[A static business card site built as a placeholder for HR Management while the full site was under development. The placeholder approach# The scope was deliberate and minimal: a single-page HTML/CSS/JS site with a four-panel slideshow — fast to build and fast to load. No CMS, no backend, no moving parts to maintain. The goal was to establish an online presence quickly while the proper site was bei…]]></description>
            <content:encoded><![CDATA[<p>A static business card site built as a placeholder for HR Management while the full site was under development.</p>
<h2>The placeholder approach<a id="the-placeholder-approach" href="#the-placeholder-approach" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The scope was deliberate and minimal: a single-page HTML/CSS/JS site with a four-panel slideshow — fast to build and fast to load. No CMS, no backend, no moving parts to maintain. The goal was to establish an online presence quickly while the proper site was being built.</p>
<h2>What the slides showed<a id="what-the-slides-showed" href="#what-the-slides-showed" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>The four slides covered: the company logo and legal details (<em>&quot;Strona w przebudowie&quot;</em> — site under construction), the tagline (<em>&quot;ROZWIJAMY SIĘ Z WAMI i dla Was&quot;</em>), legal advisory and consulting contacts, and insurance brokerage and claims handling contacts.</p>
<p><img data-full="/media/hr-management-card/HR_wizytowka.webp" src="/media/hr-management-card/HR_wizytowka.webp" alt="HR Management — business card site, all four slides" />
<em>Archive screenshot of all four slideshow panels.</em></p>
<p>Replaced two months later by the full QuickCMS site: <a href="/blog/hr-management/">HR Management — full site</a>.</p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/hr-management-card/HR-wizytowka.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>js</category>
                    </item>
                <item>
            <title><![CDATA[Polufka – venue site, Gdańsk Wrzeszcz]]></title>
            <link>https://holas.pl/blog/polufka-new-venue-gdansk/</link>
            <guid isPermaLink="true">https://holas.pl/blog/polufka-new-venue-gdansk/</guid>
                        <pubDate>Wed, 18 May 2011 00:00:00 +0000</pubDate>
                        <description><![CDATA[Website for Polufka — a bar in the student district of Gdańsk Wrzeszcz, built around three things: beer, live sports, and board games. Built on WordPress with a custom PHP theme. What the venue offers# Polufka positioned itself as the go-to spot in the student district: a warm, atmospheric pub for watching sport, playing board games, and meeting over a beer. The site reflected that character — dar…]]></description>
            <content:encoded><![CDATA[<p>Website for Polufka — a bar in the student district of Gdańsk Wrzeszcz, built around three things: beer, live sports, and board games. Built on WordPress with a custom PHP theme.</p>
<h2>What the venue offers<a id="what-the-venue-offers" href="#what-the-venue-offers" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p>Polufka positioned itself as the go-to spot in the student district: a warm, atmospheric pub for watching sport, playing board games, and meeting over a beer. The site reflected that character — dark wood-paneled design, a playful tone throughout the navigation (<em>Po Piwku?</em>, <em>Po Lufte?</em>), and a blog-style news and events section keeping regulars up to date on upcoming screenings and events. Full navigation: <strong>O nas</strong>, <strong>Po Piwku?</strong>, <strong>Po Lufte?</strong>, <strong>Napoje</strong>, <strong>Przekąski</strong>, <strong>Galeria</strong>, <strong>Kontakt</strong>.</p>
<p>The sidebar featured a calendar, an upcoming events widget, partner links, and a Facebook Like Box — the standard mix for a venue with an active community of regulars.</p>
<h2>Mobile handling in 2011<a id="mobile-handling-in-2011" href="#mobile-handling-in-2011" class="heading-anchor" aria-hidden="true" title="Permalink">#</a></h2>
<p><img data-full="/media/polufka-gdansk/polufka_screenshot.webp" src="/media/polufka-gdansk/polufka_screenshot_crop.webp" alt="Polufka — archived site, 2013" /></p>
<p>The notable aspect of this project was the mobile handling: the site shipped with both a full desktop version and a dedicated mobile version, served via user-agent detection. This was the standard approach in 2011 — Ethan Marcotte had published the responsive web design concept just a year earlier, and separate mobile templates were still common practice. The two-template setup meant the mobile experience could be tailored specifically to small screens without compromising the desktop layout.</p>
<p><em>The original site is archived on <a rel="nofollow noopener noreferrer" target="_blank" href="https://web.archive.org/web/20130509182358/http://polufka.pl/">Wayback Machine</a>. Polufka is still active — now at a new location in Wrzeszcz with a <a rel="nofollow noopener noreferrer" target="_blank" href="https://polufka.pl/">new site</a>.</em></p>
]]></content:encoded>
                        <media:content url="https://holas.pl/media/polufka-gdansk/polufka.webp" medium="image" type="image/webp" width="1280" height="720"/>
                                    <category>projects</category>
                                    <category>php</category>
                        <category>wordpress</category>
                    </item>
            </channel>
</rss>
